Compare commits

..
5 Commits
Author SHA1 Message Date
JesterDev 3e642e0986 v1.19.0 2026-10-24 14:17:39 +02:00
JesterDev d8113534ea v1.18.3 2026-10-19 19:32:31 +02:00
JesterDev 5128f49f8b Version 1.18.2 2026-10-16 19:36:37 +02:00
JesterDev c8a0466854 Version 1.18.1 2026-10-16 19:11:15 +02:00
JesterDev 3314a0f1b0 Version 1.18.0 2026-10-16 19:06:32 +02:00
13 changed files with 1508 additions and 170 deletions

No files matched your search

+44 -3
View File
@@ -1,4 +1,4 @@
PS5 DIRECTO 1.18
PS5 DIRECTO 1.19
================
(English: see README_EN.txt)
@@ -24,8 +24,15 @@ PESTAÑAS
luego "Pegar aquí" en la carpeta de destino), borrar, bajar al PC
(a Descargas\PS5 Directo), poner permisos 777 y subir archivos o
carpetas tal cual a la carpeta en la que estás.
Copiar, mover, borrar y calcular tamaños necesitan BFpilot; sin él, el
explorador funciona en solo lectura por FTP.
Clic derecho en un archivo o en el hueco para el menú; atajos: Supr,
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A y Retroceso. "Mover a…" y "Copiar
a…" abren un selector de carpetas con los destinos recientes. Si al pegar
ya existe algo con el mismo nombre, eliges reemplazar/combinar, conservar
ambos u omitir. "Extraer…" descomprime en la consola un .rar/.7z/.zip que
ya esté allí. A la izquierda tienes las unidades (con su espacio libre) y
accesos rápidos. Puedes arrastrar archivos del PC a la carpeta abierta.
Sin BFpilot casi todo funciona por FTP (copiar pasa por el PC, más
lento); descomprimir y ver el espacio libre sí necesitan BFpilot.
- Espacio: gráfico de quesitos con lo que ocupa sitio en la consola, con
el nombre de cada juego y su tamaño. Avisa de juegos repetidos (por
ejemplo la carpeta y su versión convertida a imagen), envíos a medias y
@@ -168,6 +175,40 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
Ajustes.
NOVEDADES 1.19
- Explorador de archivos renovado: menú de clic derecho, atajos de teclado,
ruta por tramos, unidades y accesos a la izquierda, "Mover a…" / "Copiar
a…" con destinos recientes, aviso al pegar si ya existe (reemplazar,
combinar, conservar ambos u omitir), "Extraer…" de comprimidos que ya
están en la consola, y arrastrar archivos del PC a la carpeta abierta.
- El explorador ya no necesita BFpilot para casi nada: renombrar, mover,
borrar, crear carpetas, permisos, tamaños y copiar funcionan solo con FTP.
- Campana de notificaciones arriba: transferencias terminadas o con error,
operaciones de archivos, desconexiones de la consola y versiones nuevas.
NOVEDADES 1.18.4
- Arreglado: si dejabas la ventana minimizada o en segundo plano un rato, el
programa se cerraba solo y al volver salía "Failed to fetch". Ahora solo se
cierra cuando cierras la ventana de verdad.
NOVEDADES 1.18.3
- Arreglado "Failed to fetch" y la cola que aparecía y desaparecía: en Windows
podían quedar dos PS5 Directo escuchando en el mismo puerto (por ejemplo al
abrirlo dos veces seguidas). Ahora solo puede haber uno abierto.
- Si la conexión con el programa falla un instante, se reintenta sola.
NOVEDADES 1.18.2
- Crea automáticamente un acceso directo "PS5 Directo" en el escritorio (y en "Enviar a")
con el icono del programa. Si lo borras no vuelve a aparecer; si apuntaba a
una copia vieja del programa, se corrige.
- La barra de tareas enseña el icono nuevo mientras el programa está abierto.
- Si se había quedado abierta una versión antigua (por ejemplo una copia vieja
en otra carpeta), al abrir esta se cierra la vieja sola en vez de enseñarla.
NOVEDADES 1.18.1
- Al borrar algo grande se ve cuánto se ha liberado y el tiempo que lleva, y el
espacio libre del explorador se actualiza mientras tanto.
NOVEDADES 1.18
- Pestaña "Espacio": gráfico de quesitos y lista de todo lo que ocupa sitio en
la consola, con el nombre de cada juego y su tamaño. Marca juegos repetidos
+2 -1
View File
@@ -10,7 +10,8 @@ A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the
- ✅ Extracts on the console (BFpilot) or on the PC (7-Zip, every file CRC-checked)
- ✅ Fixes the file permissions that stop BFpilot-extracted content from launching (CE-107750-0)
- ✅ Pause, resume and retry where it left off, even after a dropped connection
- ✅ Built-in console file browser: sizes, copy, move, rename, delete, download to PC, upload
- ✅ Built-in console file browser (works over plain FTP, BFpilot optional): right-click menu, shortcuts, move/copy to…, extract archives on the console, drag & drop uploads, download to PC
- ✅ Notification bell: finished or failed transfers, disconnections and new versions
- ✅ Storage view: a pie chart of what fills the console, by game name and size, flagging duplicates and leftovers you can delete
- ✅ Send whole PC folders over several connections, skipping what's already there and resuming what got cut off
- ✅ Watches free space so the console never fills up
+35 -4
View File
@@ -1,5 +1,5 @@
PS5 DIRECTO 1.18 — USER GUIDE (English)
=======================================
PS5 DIRECTO 1.19 — USER GUIDE (English)
=========================================
Paste links (or drag files from your PC) and the tool does the rest:
@@ -23,8 +23,15 @@ TABS
destination folder), delete, download to the PC (to Downloads\PS5
Directo), set 777 permissions, and upload files or folders as-is into
the current folder.
Copy, move, delete and folder sizes need BFpilot; without it the
browser is read-only over FTP.
Right-click a file or the empty space for the menu; shortcuts: Del,
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A and Backspace. "Move to…" and
"Copy to…" open a folder picker with recent destinations. If something
with the same name already exists when pasting, you choose replace/merge,
keep both or skip. "Extract…" unpacks a .rar/.7z/.zip that is already on
the console. Drives (with free space) and quick places are on the left.
You can drag files from the PC onto the open folder.
Without BFpilot almost everything works over FTP (copying goes through
the PC, slower); extracting and showing free space do need BFpilot.
- Space: a pie chart of what takes up space on the console, with each
game's name and size. It flags repeated games (for example a folder and
its version converted to an image), half-sent transfers and leftovers,
@@ -220,6 +227,30 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo
WHAT'S NEW
----------
1.19 - Renewed file browser: right-click menu, keyboard shortcuts,
clickable path, drives and places on the left, "Move to…" / "Copy
to…" with recent destinations, a choice when pasting over existing
items (replace, merge, keep both or skip), "Extract…" for archives
already on the console, and dragging files from the PC onto the
open folder.
- The file browser no longer needs BFpilot for most things: rename,
move, delete, new folder, permissions, sizes and copy work over FTP.
- Notification bell at the top: finished or failed transfers, file
operations, console disconnections and new versions.
1.18.4 - Fixed: leaving the window minimized or in the background for a while
made the program close itself, showing "Failed to fetch" on return.
Now it only closes when you actually close the window.
1.18.3 - Fixed "Failed to fetch" and the queue appearing and disappearing: on
Windows two PS5 Directo could end up listening on the same port (e.g.
when opened twice in a row). Now only one can be open. Brief
connection hiccups are retried automatically.
1.18.2 - Creates a "PS5 Directo" shortcut on the desktop (and in "Send to")
with the program's icon. If you delete it, it won't come back; if it
pointed to an old copy, it's fixed. The taskbar shows the new icon.
If an old version was still open (e.g. an old copy in another folder),
it's closed automatically instead of being shown.
1.18.1 - When deleting something big you see how much has been freed and how
long it has taken, and the browser's free space updates meanwhile.
1.18 - "Space" tab: a pie chart and list of everything that takes up space
on the console, by game name and size. It flags repeated games (for
example a folder and its converted version), half-sent transfers and
Binary file not shown.
+667 -47
View File
@@ -22,7 +22,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import engine as E # noqa: E402
VERSION = "1.18.0"
VERSION = "1.19.0"
# Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas,
# todas las copias buscan actualizaciones ahi sin tener que configurar nada.
DEFAULT_REPO = "jstrdev/ps5directo"
@@ -55,6 +55,8 @@ DEFAULTS = {
"delete_archives": True,
"tour_done": False, # la guia de bienvenida ya se vio
"seen_version": "", # ultima version cuyas novedades ya se ensenaron
"shortcut_done": False, # ya se creo el acceso directo del escritorio una vez
"icon_ver": "", # version cuyo icono ya se dejo en la cache de la ventana
}
LOCK = threading.RLock()
@@ -79,6 +81,7 @@ def _save_json(path, obj):
def load_state():
global JOBS
load_notifs()
try:
with open(SETTINGS_FILE, encoding="utf-8") as f:
SETTINGS.update(json.load(f))
@@ -573,10 +576,49 @@ def record_history(job):
"dest": job.get("dest") or "",
})
_save_json(HISTORY_FILE, hist[-500:])
title = job.get("title") or "Transferencia"
link = {"tab": "Transfers", "job": job["id"]}
if job.get("status") == "hecho":
notify("ok", "Terminado: %s" % title, ", ".join(job.get("result") or [])[:300], link)
elif job.get("status") == "error":
notify("err", "Error: %s" % title, job.get("error") or "", link)
except Exception:
traceback.print_exc()
NOTIF_FILE = os.path.join(DATA_DIR, "notifications.json")
NOTIF_LOCK = threading.Lock()
NOTIFS = [] # lo mas nuevo primero
def load_notifs():
try:
with open(NOTIF_FILE, encoding="utf-8") as f:
NOTIFS[:] = json.load(f)[:100]
except Exception:
pass
def save_notifs():
with NOTIF_LOCK:
data = list(NOTIFS)
try:
_save_json(NOTIF_FILE, data)
except Exception:
pass
def notify(level, title, body="", link=None, key=None):
"""Aviso para la campana. level: ok, info, warn, err. key evita repetir el mismo aviso."""
with NOTIF_LOCK:
if key and any(n.get("key") == key for n in NOTIFS):
return
NOTIFS.insert(0, {"id": secrets.token_hex(4), "t": time.time(), "level": level, "title": title,
"body": (body or "")[:600], "link": link, "key": key, "read": False})
del NOTIFS[100:]
save_notifs()
def read_history():
try:
with open(HISTORY_FILE, encoding="utf-8") as f:
@@ -648,13 +690,177 @@ def fm_bf():
return bf
def _fm_start(op, items_total, fn):
def fm_bf_opt():
"""BFpilot si esta cargado, o None (entonces se hace por FTP)."""
bf = E.BFpilot(cfg_snapshot())
return bf if bf.alive() else None
# ---- lo mismo por FTP, para cuando BFpilot no esta cargado -------------------
def _ftp_entries(f, path):
"""[(nombre, es_carpeta, tamano)] de una carpeta remota."""
out = []
try:
for name, facts in f.mlsd(path, facts=["type", "size"]):
t = facts.get("type")
if t in ("file", "dir"):
out.append((name, t == "dir", int(facts.get("size") or 0)))
finally:
try:
f.voidcmd("TYPE I")
except Exception:
pass
return out
def _ftp_kind(f, path):
"""'dir', 'file' o None."""
parent, name = posixpath.dirname(path) or "/", posixpath.basename(path)
try:
for n, d, _ in _ftp_entries(f, parent):
if n == name:
return "dir" if d else "file"
except Exception:
pass
return None
def _ftp_walk(f, root, cancel=None):
"""(archivos [(ruta, rel, tamano)], carpetas [rel]) de una carpeta, de arriba abajo."""
files, dirs = [], []
stack = [""]
while stack:
if cancel is not None and cancel.is_set():
raise E.Cancelled()
rel = stack.pop()
cur = root + ("/" + rel if rel else "")
for n, d, sz in _ftp_entries(f, cur):
r = (rel + "/" + n).lstrip("/")
if d:
dirs.append(r)
stack.append(r)
else:
files.append((cur + "/" + n, r, sz))
return files, dirs
def ftp_du(cfg, path):
def go(f):
k = _ftp_kind(f, path)
if k == "file":
return int(f.size(path) or 0)
files, _ = _ftp_walk(f, path)
return sum(sz for _, _, sz in files)
return E.Ftp(cfg).call(go)
def ftp_rmtree(cfg, path, cancel, on_item=None):
def go(f):
k = _ftp_kind(f, path)
if k is None:
return
if k == "file":
f.delete(path)
return
files, dirs = _ftp_walk(f, path, cancel)
for i, (rp, _, _) in enumerate(files):
if cancel.is_set():
raise E.Cancelled()
if on_item:
on_item(rp, i, len(files))
f.delete(rp)
for d in sorted(dirs, key=lambda x: -x.count("/")):
f.rmd(path + "/" + d)
f.rmd(path)
E.Ftp(cfg).call(go)
def ftp_chmod_tree(cfg, path, cancel):
def go(f):
n = 1
f.sendcmd("SITE CHMOD 777 " + path)
if _ftp_kind(f, path) == "dir":
files, dirs = _ftp_walk(f, path, cancel)
for d in dirs:
f.sendcmd("SITE CHMOD 777 %s/%s" % (path, d))
for rp, _, _ in files:
if cancel.is_set():
raise E.Cancelled()
f.sendcmd("SITE CHMOD 777 " + rp)
n += len(files) + len(dirs)
return n
return E.Ftp(cfg).call(go)
def ftp_relay_copy(cfg, src, target, cancel, progress):
"""Copia dentro de la consola pasando por el PC (sin BFpilot): lee por una conexion FTP
y escribe por otra. progress(bytes_hechos, bytes_totales, ruta_actual)."""
a = E.Ftp(cfg).connect()
b = E.Ftp(cfg).connect()
try:
k = _ftp_kind(a, src)
if k is None:
raise E.PipeError("No existe %s" % src)
if k == "file":
files, dirs = [(src, "", int(a.size(src) or 0))], []
else:
files, dirs = _ftp_walk(a, src, cancel)
E.Ftp.mkdirs(b, target)
for d in dirs:
E.Ftp.mkdirs(b, target + "/" + d)
total = sum(sz for _, _, sz in files)
done = 0
for rp, rel, sz in files:
if cancel.is_set():
raise E.Cancelled()
dst = target + ("/" + rel if rel else "")
E.Ftp.mkdirs(b, posixpath.dirname(dst))
cr = a.transfercmd("RETR " + rp)
cw = b.transfercmd("STOR " + dst)
try:
while True:
if cancel.is_set():
raise E.Cancelled()
chunk = cr.recv(E.MiB)
if not chunk:
break
cw.sendall(chunk)
done += len(chunk)
progress(done, total, rp)
finally:
cr.close()
cw.close()
a.voidresp()
b.voidresp()
got = E.Ftp.size(b, dst)
if got is not None and got != sz:
raise E.PipeError("La copia de %s salio incompleta (%s de %s)" % (rp, got, sz))
finally:
for f in (a, b):
try:
f.quit()
except Exception:
pass
# ---- operaciones largas con progreso -----------------------------------------
OP_DONE = {"copy": "Copiado", "move": "Movido", "delete": "Borrado", "download": "Descargado al PC",
"chmod": "Permisos puestos", "extract": "Extraído"}
OP_FAIL = {"copy": "Error al copiar", "move": "Error al mover", "delete": "Error al borrar",
"download": "Error al descargar", "chmod": "Error al poner permisos", "extract": "Error al extraer"}
def _fm_start(op, items_total, fn, where=None, names=None):
with LOCK:
if FMOP["busy"]:
raise E.PipeError("Ya hay una operacion en marcha en el explorador")
FMOP.update(busy=True, op=op, current="", items_done=0, items_total=items_total, bytes_done=0,
bytes_total=0, error="", message="", t0=time.time(), local="")
FM_CANCEL.clear()
what = ", ".join((names or [])[:3]) + ("…" if names and len(names) > 3 else "")
link = {"tab": "Files", "path": where} if where else None
def run():
try:
@@ -662,6 +868,10 @@ def _fm_start(op, items_total, fn):
with LOCK:
if not FMOP["message"]:
FMOP["message"] = "Hecho"
msg = FMOP["message"]
if time.time() - FMOP["t0"] > 8 or op in ("extract",):
notify("ok", "%s: %s" % (OP_DONE.get(op, op), what) if what else OP_DONE.get(op, op),
msg if msg != "Hecho" else "", link)
except E.Cancelled:
with LOCK:
FMOP["error"] = "Cancelado"
@@ -669,68 +879,232 @@ def _fm_start(op, items_total, fn):
traceback.print_exc()
with LOCK:
FMOP["error"] = str(e)
notify("err", "%s: %s" % (OP_FAIL.get(op, op), what) if what else OP_FAIL.get(op, op),
str(e), link)
finally:
with LOCK:
FMOP["busy"] = False
threading.Thread(target=run, daemon=True).start()
def fm_paste(op, srcs, dst, overwrite=False):
bf = fm_bf()
def _names_in(path):
try:
return {e["name"]: e["dir"] for e in fm_list(path)["entries"]}
except Exception:
return {}
def _free_name(name, taken):
"""'juego' -> 'juego (2)'; 'a.zip' -> 'a (2).zip'."""
base, ext = name, ""
m = re.match(r"^(.*?)(\.[A-Za-z0-9]{1,5})$", name)
if m and m.group(1):
base, ext = m.group(1), m.group(2)
k = 2
while "%s (%d)%s" % (base, k, ext) in taken:
k += 1
return "%s (%d)%s" % (base, k, ext)
def fm_conflicts(srcs, dst):
dst = fm_clean(dst)
have = _names_in(dst)
out = []
for s in srcs:
s = fm_clean(s)
n = posixpath.basename(s)
if n in have:
out.append({"name": n, "dir": have[n], "same": posixpath.dirname(s) == dst})
return out
def fm_paste(op, srcs, dst, mode="replace"):
"""mode: replace (reemplaza archivos / combina carpetas), skip, keepboth."""
dst = fm_clean(dst)
srcs = [fm_clean(x) for x in srcs]
if mode not in ("replace", "skip", "keepboth"):
mode = "replace"
for s in srcs:
if dst == s or dst.startswith(s + "/"):
raise E.PipeError("No se puede %s una carpeta dentro de si misma" % ("mover" if op == "move" else "copiar"))
cfg = cfg_snapshot()
bf = fm_bf_opt()
def bf_job(kind, s, d, overwrite):
bf._req("POST", "/api/fs/" + kind, form={"src": s, "dst": d, "overwrite": "1" if overwrite else "0"})
while True:
time.sleep(0.7)
if FM_CANCEL.is_set():
try:
bf._req("GET", "/api/fs/job/cancel")
except Exception:
pass
j = bf._req("GET", "/api/fs/job/status")
with LOCK:
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
current=j.get("current") or s)
if not j.get("busy"):
if j.get("error"):
raise E.PipeError(j["error"])
break
if FM_CANCEL.is_set():
raise E.Cancelled()
def relay(s, target):
def prog(done, total, cur):
with LOCK:
FMOP.update(bytes_done=done, bytes_total=total, current=cur)
ftp_relay_copy(cfg, s, target, FM_CANCEL, prog)
def work():
taken = set(_names_in(dst))
for i, s in enumerate(srcs):
if FM_CANCEL.is_set():
raise E.Cancelled()
name = posixpath.basename(s)
same_dir = posixpath.dirname(s) == dst
with LOCK:
FMOP.update(current=s, items_done=i, bytes_done=0, bytes_total=0)
if op == "move" and posixpath.dirname(s) == dst:
if op == "move" and same_dir:
continue
bf._req("POST", "/api/fs/" + op, form={"src": s, "dst": dst, "overwrite": "1" if overwrite else "0"})
while True:
time.sleep(0.7)
if FM_CANCEL.is_set():
new = name
if name in taken:
if mode == "skip":
continue
if mode == "keepboth" or same_dir:
new = _free_name(name, taken)
taken.add(new)
target = dst.rstrip("/") + "/" + new
if bf is not None:
if new == name:
bf_job(op, s, dst, mode == "replace")
else:
# BFpilot copia con el mismo nombre: a una carpeta temporal y luego se renombra
tmp = "%s/.ps5d-%s" % (dst.rstrip("/"), secrets.token_hex(3))
bf.mkdir(tmp)
try:
bf._req("GET", "/api/fs/job/cancel")
bf_job(op, s, tmp, False)
bf.rename(tmp + "/" + name, target)
finally:
try:
bf.delete(tmp)
except Exception:
pass
else:
if op == "move":
try:
E.Ftp(cfg).call(lambda f: f.rename(s, target))
continue
except Exception:
pass
j = bf._req("GET", "/api/fs/job/status")
with LOCK:
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
current=j.get("current") or s)
if not j.get("busy"):
if j.get("error"):
raise E.PipeError(j["error"])
break
if FM_CANCEL.is_set():
raise E.Cancelled()
pass # otro disco o ya existe: copia y luego borra
relay(s, target)
if op == "move":
ftp_rmtree(cfg, s, FM_CANCEL)
with LOCK:
FMOP["items_done"] = len(srcs)
_fm_start(op, len(srcs), work)
_fm_start(op, len(srcs), work, where=dst, names=[posixpath.basename(x) for x in srcs])
def fm_delete(paths):
bf = fm_bf()
paths = [fm_clean(x) for x in paths]
for p in paths:
if p.count("/") < 2:
raise E.PipeError("Por seguridad no borro carpetas de primer nivel (%s)" % p)
bf = fm_bf_opt()
cfg = cfg_snapshot()
def free_now():
if bf is None:
return None
try:
return int(bf._req("GET", "/api/fs/stat", {"path": paths[0].rsplit("/", 1)[0] or "/"},
timeout=8).get("availableBytes") or 0)
except Exception:
return None
def work():
start = free_now()
last = {"t": 0.0}
def tick():
# Borrar un juego grande tarda: ensenamos lo que se va liberando para que se vea que avanza
if start is None or time.time() - last["t"] < 3:
return
last["t"] = time.time()
now = free_now()
if now is not None:
with LOCK:
FMOP["bytes_done"] = max(0, now - start)
FMOP["message"] = ""
for i, p in enumerate(paths):
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP.update(current=p, items_done=i)
bf.delete(p, FM_CANCEL)
if bf is not None:
bf.delete(p, FM_CANCEL, on_tick=tick)
else:
ftp_rmtree(cfg, p, FM_CANCEL,
lambda rp, k, n: FMOP.update(current=rp))
tick()
with LOCK:
FMOP["items_done"] = len(paths)
_fm_start("delete", len(paths), work)
if FMOP["bytes_done"]:
FMOP["message"] = "Hecho: liberados %s" % human(FMOP["bytes_done"])
_fm_start("delete", len(paths), work, where=posixpath.dirname(paths[0]) if paths else None,
names=[posixpath.basename(x) for x in paths])
def fm_du(path):
path = fm_clean(path)
bf = fm_bf_opt()
if bf is not None:
return bf.du(path, timeout=300)
return ftp_du(cfg_snapshot(), path)
def fm_extract(src, folder, password="", exists="keepboth"):
"""Descomprime un .rar/.7z/.zip que ya esta en la consola, en una carpeta a su lado."""
src = fm_clean(src)
bf = E.BFpilot(cfg_snapshot())
if not bf.alive():
raise E.PipeError("Descomprimir en la consola necesita BFpilot cargado en la PS5")
parent, name = posixpath.dirname(src) or "/", posixpath.basename(src)
if not E.is_archive(name):
raise E.PipeError("Eso no es un comprimido (.rar, .7z o .zip)")
have = _names_in(parent)
# de un .part3.rar o un .7z.002 se extrae desde el primero
group = [n for n in have if not have[n] and E.set_key(n) == E.set_key(name) and E.is_archive(n)]
first = E.first_volume(group or [name]) or name
src = parent.rstrip("/") + "/" + first
folder = (folder or "").strip().strip("/")
if not folder or "/" in folder or folder in (".", ".."):
raise E.PipeError("Pon un solo nombre de carpeta, sin /")
if folder in have: # BFpilot no extrae sobre una carpeta que ya existe
folder = _free_name(folder, set(have))
dst = parent.rstrip("/") + "/" + folder
def work():
with LOCK:
FMOP.update(current=first)
def st(j):
with LOCK:
FMOP.update(bytes_done=int(j.get("bytesWritten") or 0), bytes_total=int(j.get("totalBytes") or 0),
current=j.get("current") or first)
try:
j = bf.extract(src, dst, password, FM_CANCEL, st)
except E.PipeError as e:
if E.is_password_error(str(e)):
raise E.PipeError("Contraseña incorrecta o necesaria: vuelve a intentarlo con la contraseña")
raise
try:
bf.fix_perms(dst, FM_CANCEL)
except Exception:
pass
with LOCK:
FMOP.update(items_done=1, message="Extraído en %s" % dst)
_fm_start("extract", 1, work, where=parent, names=[first])
def fm_download(paths):
@@ -788,11 +1162,12 @@ def fm_download(paths):
with LOCK:
FMOP.update(items_done=len(files), bytes_done=done,
message="Guardado en %s" % root_local)
_fm_start("download", len(paths), work)
_fm_start("download", len(paths), work, names=[posixpath.basename(x) for x in paths])
def fm_chmod(paths):
bf = fm_bf()
bf = fm_bf_opt()
cfg = cfg_snapshot()
paths = [fm_clean(x) for x in paths]
def work():
@@ -800,7 +1175,10 @@ def fm_chmod(paths):
for i, p in enumerate(paths):
with LOCK:
FMOP.update(current=p, items_done=i)
c, _, _ = bf.fix_perms(p, FM_CANCEL)
if bf is not None:
c, _, _ = bf.fix_perms(p, FM_CANCEL)
else:
c = ftp_chmod_tree(cfg, p, FM_CANCEL)
changed += c
with LOCK:
FMOP.update(items_done=len(paths), message="Permisos 777 puestos en %d elementos" % changed)
@@ -975,10 +1353,38 @@ def fm_api(p, b):
if p == "/api/space/cancel":
SPACE_CANCEL.set()
return {}
if p == "/api/fm/stat":
st = E.BFpilot(cfg_snapshot())._req("GET", "/api/fs/stat", {"path": fm_clean(b.get("path") or "/data")}, timeout=8)
return {"free": st.get("availableBytes"), "total": st.get("usableTotalBytes") or st.get("totalBytes")}
if p == "/api/fm/list":
return fm_list(b.get("path") or "/data")
if p == "/api/fm/du":
return {"size": fm_bf().du(fm_clean(b.get("path")))}
return {"size": fm_du(b.get("path"))}
if p == "/api/fm/dirs":
path = fm_clean(b.get("path") or "/data")
return {"path": path, "dirs": sorted([e["name"] for e in fm_list(path)["entries"] if e["dir"]],
key=str.lower)}
if p == "/api/fm/conflicts":
return {"conflicts": fm_conflicts(b.get("srcs") or [], b.get("dst") or "/")}
if p == "/api/fm/extract":
fm_extract(b.get("src"), b.get("folder"), str(b.get("password") or ""), b.get("exists") or "keepboth")
return {}
if p == "/api/fm/drives":
bf = fm_bf_opt()
out = [{"path": "/data", "name": "Almacenamiento interno"}]
if bf is not None:
for m in (bf.places() or {}).get("mounts") or []:
mp = (m.get("path") or "").rstrip("/")
if re.match(r"^/mnt/(usb|ext)\d+$", mp):
out.append({"path": mp, "name": ("USB " if "/usb" in mp else "Disco externo ") + mp[-1]})
for d in out:
try:
st = bf._req("GET", "/api/fs/stat", {"path": d["path"]}, timeout=6)
d["free"] = st.get("availableBytes")
d["total"] = st.get("usableTotalBytes") or st.get("totalBytes")
except Exception:
pass
return {"drives": out, "bfpilot": bf is not None}
if p == "/api/fm/mkdir":
path = fm_clean(b.get("path"))
bf = E.BFpilot(cfg_snapshot())
@@ -989,10 +1395,10 @@ def fm_api(p, b):
return {}
if p == "/api/fm/rename":
src, dst = fm_clean(b.get("src")), fm_clean(b.get("dst"))
if posixpath.basename(dst) in _names_in(posixpath.dirname(dst) or "/"):
raise E.PipeError("Ya existe algo llamado %s en esa carpeta" % posixpath.basename(dst))
bf = E.BFpilot(cfg_snapshot())
if bf.alive():
if bf.exists(dst):
raise E.PipeError("Ya existe %s" % dst)
bf.rename(src, dst)
else:
E.Ftp(cfg_snapshot()).call(lambda f: f.rename(src, dst))
@@ -1001,7 +1407,7 @@ def fm_api(p, b):
op = b.get("op")
if op not in ("copy", "move"):
raise E.PipeError("operacion no valida")
fm_paste(op, b.get("srcs") or [], b.get("dst"), bool(b.get("overwrite")))
fm_paste(op, b.get("srcs") or [], b.get("dst"), b.get("mode") or ("replace" if b.get("overwrite") else "replace"))
return {}
if p == "/api/fm/delete":
fm_delete(b.get("paths") or [])
@@ -1027,6 +1433,26 @@ def fm_api(p, b):
return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")}
if p == "/api/history":
return {"items": read_history()}
if p == "/api/notifs":
with NOTIF_LOCK:
return {"items": list(NOTIFS), "unread": sum(1 for n in NOTIFS if not n.get("read"))}
if p == "/api/notifs/read":
with NOTIF_LOCK:
for n in NOTIFS:
if b.get("all") or n["id"] == b.get("id"):
n["read"] = not b.get("unread")
save_notifs()
return {}
if p == "/api/notifs/remove":
with NOTIF_LOCK:
NOTIFS[:] = [n for n in NOTIFS if n["id"] != b.get("id")]
save_notifs()
return {}
if p == "/api/notifs/clear":
with NOTIF_LOCK:
NOTIFS[:] = []
save_notifs()
return {}
if p == "/api/history/clear":
_save_json(HISTORY_FILE, [])
return {}
@@ -2031,7 +2457,28 @@ def extract_worker():
TOKEN = secrets.token_urlsafe(18)
CONN_STATE = {"ftp": None, "bf": None}
def _conn_change(kind, ok, detail):
"""Avisa solo cuando algo que funcionaba deja de funcionar (y cuando vuelve)."""
name = "el FTP" if kind == "ftp" else "BFpilot"
prev = CONN_STATE[kind]
CONN_STATE[kind] = ok
if prev is True and not ok:
notify("warn", "Se perdió la conexión con %s de la PS5" % name, detail)
elif prev is False and ok:
notify("info", "Otra vez conectado con %s de la PS5" % name)
def test_connection(cfg):
out = _test_connection(cfg)
_conn_change("ftp", out["ftp"] == "ok", out["ftp"])
_conn_change("bf", str(out["bf"]).startswith("ok"), out["bf"])
return out
def _test_connection(cfg):
out = {"ftp": None, "bf": None, "free": None}
try:
f = E.Ftp(cfg).connect()
@@ -2535,7 +2982,19 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/logo.svg":
with open(os.path.join(HERE, "web", "logo.svg"), "rb") as f:
return self._send(200, f.read(), "image/svg+xml")
if self.path == "/favicon.ico":
if self.path.startswith("/icon-") and self.path.split("?")[0].endswith(".png"):
n = re.sub(r"\D", "", self.path.split("?")[0])
fp = os.path.join(HERE, "web", "icon-%s.png" % n)
if n and os.path.isfile(fp):
with open(fp, "rb") as f:
return self._send(200, f.read(), "image/png")
if self.path.split("?")[0] == "/manifest.webmanifest":
man = {"name": "PS5 Directo", "short_name": "PS5 Directo", "display": "standalone",
"background_color": "#0f1115", "theme_color": "#0f1115", "start_url": "/",
"icons": [{"src": "/icon-%d.png?v=%s" % (n, VERSION), "sizes": "%dx%d" % (n, n),
"type": "image/png"} for n in (192, 256, 512)]}
return self._send(200, json.dumps(man).encode(), "application/manifest+json")
if self.path.split("?")[0] == "/favicon.ico":
with open(os.path.join(HERE, "web", "ps5directo.ico"), "rb") as f:
return self._send(200, f.read(), "image/x-icon")
if self.path in ("/", "/index.html") or self.path.startswith("/?"):
@@ -2548,6 +3007,7 @@ class Handler(BaseHTTPRequestHandler):
return
if self.headers.get("X-UI"):
UI_SEEN["t"] = time.time()
UI_SEEN["bye"] = 0.0
with LOCK:
jobs = []
for j in JOBS:
@@ -2555,7 +3015,8 @@ class Handler(BaseHTTPRequestHandler):
d["has_password"] = bool(j.get("password"))
d["pcx"] = (j.get("info") or {}).get("mode") == "pcx"
jobs.append(d)
st = {"ok": True, "version": VERSION, "settings": dict(SETTINGS), "jobs": jobs,
st = {"ok": True, "version": VERSION, "home": HERE,
"unread": sum(1 for n in NOTIFS if not n.get("read")), "settings": dict(SETTINGS), "jobs": jobs,
"inbox": len(INBOX), "repo": SETTINGS.get("update_repo") or DEFAULT_REPO}
st["settings"].pop("ftp_pass", None)
return self._send(200, st)
@@ -2567,6 +3028,12 @@ class Handler(BaseHTTPRequestHandler):
or o.startswith("extension://")
def do_POST(self):
if self.path.split("?")[0] == "/api/bye":
# la ventana se esta cerrando (sendBeacon no deja poner cabeceras: token en la URL)
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
if (q.get("t") or [""])[0] == TOKEN:
UI_SEEN["bye"] = time.time()
return self._send(200, {"ok": True})
if self.path == "/api/ext/capture":
if not self._from_extension():
return self._send(403, {"ok": False, "error": "solo desde la extension"})
@@ -2617,6 +3084,9 @@ class Handler(BaseHTTPRequestHandler):
ver, url, notes = github_latest(update_repo())
except Exception as e:
return self._send(200, {"ok": False, "error": str(e)})
if _ver_tuple(ver) > _ver_tuple(VERSION):
notify("info", "Hay una versión nueva: %s" % ver, "Pulsa Actualizar arriba para instalarla.",
{"action": "update"}, key="update:" + ver)
return self._send(200, {"ok": True, "current": VERSION, "latest": ver, "notes": notes,
"newer": _ver_tuple(ver) > _ver_tuple(VERSION)})
if p == "/api/update/github":
@@ -2674,7 +3144,7 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
bases = ["/data/homebrew"]
return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]})
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p in ("/api/pick", "/api/history", "/api/history/clear"):
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p.startswith("/api/notifs") or p in ("/api/pick", "/api/history", "/api/history/clear"):
try:
return self._send(200, dict(ok=True, **fm_api(p, b)))
except E.PipeError as e:
@@ -2853,10 +3323,48 @@ def locate_files(files, budget=12.0, max_depth=6):
return found
class Server(ThreadingHTTPServer):
"""En Windows, SO_REUSEADDR deja que DOS programas escuchen en el mismo puerto y las
peticiones se reparten al azar entre los dos ("Failed to fetch", la cola que aparece y
desaparece). Aqui el puerto es exclusivo."""
allow_reuse_address = os.name != "nt"
daemon_threads = True
def server_bind(self):
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
super().server_bind()
LOCK_PORT = 8776
_instance_lock = []
def take_instance_lock(wait=0.0):
"""Solo un PS5 Directo a la vez: se queda con un puerto fijo mientras funciona.
Devuelve False si otro ya lo tiene (tras esperar hasta `wait` segundos)."""
end = time.time() + wait
while True:
s = socket.socket()
try:
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
s.bind(("127.0.0.1", LOCK_PORT))
_instance_lock.append(s)
return True
except OSError:
s.close()
if time.time() >= end:
return False
time.sleep(0.25)
def pick_port():
for port in (8777, 8778, 8779, 0):
s = socket.socket()
try:
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
s.bind(("127.0.0.1", port))
p = s.getsockname()[1]
s.close()
@@ -2881,20 +3389,45 @@ def dropped_paths():
return out
def running_session():
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando."""
def running_session(full=False):
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando
(con full=True, (url_base, token, estado))."""
try:
with open(SESSION_FILE, encoding="utf-8") as f:
ses = json.load(f)
base = "http://127.0.0.1:%d" % int(ses["port"])
req = urllib.request.Request(base + "/api/state", headers={"X-Token": ses["token"]})
with urllib.request.urlopen(req, timeout=3) as r:
json.load(r)
return base, ses["token"]
st = json.load(r)
return (base, ses["token"], st) if full else (base, ses["token"])
except Exception:
return None
def replace_other_version(ses):
"""Si lo que ya esta abierto es OTRA version (o la de otra carpeta, por ejemplo una copia
vieja del escritorio) y no tiene nada en marcha, la cierra para abrir esta.
Devuelve True si se cerro."""
base, token, st = ses
if st.get("version") == VERSION and st.get("home", HERE) == HERE:
return False
for j in st.get("jobs") or []:
if j.get("status") not in ("hecho", "error", "cancelado", "pausado"):
return False # esta trabajando: no se le corta
try:
req = urllib.request.Request(base + "/api/quit", data=b"{}",
headers={"X-Token": token, "Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=5).read()
except Exception:
pass
for _ in range(40):
time.sleep(0.25)
if not running_session():
print("Cerrada la version %s que seguia abierta" % st.get("version", "antigua"))
return True
return False
def forward_to_running(ses, paths):
base, token = ses
try:
@@ -2939,21 +3472,93 @@ def open_window(url):
return None
def _powershell(script, timeout=30):
import subprocess
return subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
capture_output=True, text=True, timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
def ensure_shortcuts():
"""Acceso directo "PS5 Directo" en el escritorio y en "Enviar a", con el icono del programa.
Se crea la primera vez; despues solo se corrige si apunta a otra carpeta (una copia vieja)."""
if os.name != "nt":
return
try:
vbs = os.path.join(HERE, "PS5 Directo.vbs")
ico = os.path.join(HERE, "web", "ps5directo.ico")
if not os.path.isfile(vbs):
return
with LOCK:
first = not SETTINGS.get("shortcut_done")
q = lambda t: "'" + t.replace("'", "''") + "'"
script = (
"$w=New-Object -ComObject WScript.Shell; $first=$%s;"
"foreach($f in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('SendTo'))){"
" $p=Join-Path $f 'PS5 Directo.lnk'; $ex=Test-Path -LiteralPath $p;"
" $l=$w.CreateShortcut($p);"
" $want='\"'+%s+'\"';"
" if($ex -and $l.Arguments -eq $want -and $l.IconLocation -like ('*'+%s+'*')){continue}"
" if(-not $ex -and -not $first){continue}"
" if($ex -and $l.Arguments -notlike '*PS5 Directo.vbs*'){continue}"
" $l.TargetPath=(Join-Path $env:WINDIR 'System32\\wscript.exe'); $l.Arguments=$want;"
" $l.WorkingDirectory=%s; $l.IconLocation=%s+',0'; $l.Description='PS5 Directo'; $l.Save();"
" 'ok '+$p }"
) % ("true" if first else "false", q(vbs), q(ico), q(HERE), q(ico))
r = _powershell(script)
if r.stdout.strip():
print("Acceso directo:", r.stdout.strip().replace("\n", " | "))
if r.returncode == 0 and first:
with LOCK:
SETTINGS["shortcut_done"] = True
save_settings()
except Exception as e:
print("No se pudo crear el acceso directo:", e)
def refresh_window_icon():
"""La ventana guarda el icono en su cache: tras actualizar se borra para que la barra
de tareas enseñe el icono actual y no el de una version vieja."""
with LOCK:
if SETTINGS.get("icon_ver") == VERSION:
return
SETTINGS["icon_ver"] = VERSION
save_settings()
prof = os.path.join(DATA_DIR, "ventana")
for sub in ("Default", ""):
for name in ("Favicons", "Favicons-journal"):
try:
os.remove(os.path.join(prof, sub, name))
except OSError:
pass
import shutil
for d in ("Web Applications", os.path.join("Default", "Web Applications")):
shutil.rmtree(os.path.join(prof, d), ignore_errors=True)
def busy():
with LOCK:
return any(j["status"] not in ("hecho", "error", "cancelado", "pausado") for j in JOBS)
UI_SEEN = {"t": 0.0}
UI_SEEN = {"t": 0.0, "bye": 0.0}
def watch_ui(grace=180):
"""Si la ventana lleva un rato cerrada y no hay nada en marcha, se cierra el programa.
Si hay trabajo, sigue en segundo plano; abre PS5 Directo otra vez para verlo."""
def watch_ui(grace=30, silent=6 * 3600):
"""Se cierra el programa cuando cierras la ventana (la ventana avisa al cerrarse) y no hay
nada en marcha. Si la ventana solo esta minimizada, el navegador la "duerme" y deja de
preguntar: eso NO cuenta como cerrada (antes el programa se apagaba y al volver salia
"Failed to fetch"). Por si el navegador se cuelga sin avisar, tras `silent` segundos
sin noticias tambien se cierra."""
while True:
time.sleep(10)
seen = UI_SEEN["t"]
if seen and time.time() - seen > grace and not busy():
seen, bye = UI_SEEN["t"], UI_SEEN["bye"]
if busy() or not seen:
continue
now = time.time()
if bye and bye >= seen - 1 and now - bye > grace:
os._exit(0)
if now - seen > silent:
os._exit(0)
@@ -2971,8 +3576,21 @@ def main():
if not running_session():
break
time.sleep(0.5)
ses = running_session()
ses = running_session(full=True)
if ses and replace_other_version(ses):
ses = None
if not ses and not take_instance_lock(wait=30 if "--after-update" in sys.argv else 4):
# otro PS5 Directo esta arrancando ahora mismo (doble clic, por ejemplo): uso ese
for _ in range(40):
ses = running_session(full=True)
if ses:
break
time.sleep(0.25)
if not ses:
print("Ya hay otro PS5 Directo abierto")
sys.exit(3)
if ses:
ses = ses[:2]
if paths:
forward_to_running(ses, paths)
print("Anadido a la cola de PS5 Directo (ya estaba abierto)")
@@ -2988,13 +3606,15 @@ def main():
for target in (transfer_worker, extract_worker):
threading.Thread(target=target, daemon=True).start()
port = pick_port()
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
srv = Server(("127.0.0.1", port), Handler)
try:
os.makedirs(DATA_DIR, exist_ok=True)
with open(SESSION_FILE, "w", encoding="utf-8") as f:
json.dump({"port": srv.server_address[1], "token": TOKEN}, f)
except Exception:
pass
threading.Thread(target=ensure_shortcuts, daemon=True).start()
refresh_window_icon()
url = "http://127.0.0.1:%d/#t=%s" % (srv.server_address[1], TOKEN)
print("PS5 Directo %s" % VERSION)
print("Abre en el navegador: %s" % url)
+14 -1
View File
@@ -44,11 +44,24 @@ async function handle(item) {
chrome.action.setBadgeText({ text: "off" });
return;
}
// Como los gestores de descargas: pasamos las cookies de ese alojamiento (sesion, cuenta
// premium), el navegador y la pagina de origen, para que el servidor nos trate igual.
// Solo van a PS5 Directo en este PC (127.0.0.1), a ningun otro sitio.
const cookies = {};
for (const u of [url, item.url]) {
try {
const host = new URL(u).host.toLowerCase();
if (host in cookies) continue;
const list = await chrome.cookies.getAll({ url: u });
cookies[host] = list.map(c => `${c.name}=${c.value}`).join("; ");
} catch (e) { /* sin permiso o enlace raro: sin cookies */ }
}
try {
const r = await fetch(`http://127.0.0.1:${app.port}/api/ext/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, original: item.url, name })
body: JSON.stringify({ url, original: item.url, name, cookies,
ua: navigator.userAgent, referrer: item.referrer || "" })
});
if (!r.ok) return;
} catch (e) { return; }
+8 -3
View File
@@ -1257,11 +1257,16 @@ class BFpilot:
except Exception:
return ""
def wait_job(self, cancel, timeout=3600):
def wait_job(self, cancel, timeout=3600, on_tick=None):
t0 = time.time()
while time.time() - t0 < timeout:
if cancel is not None and cancel.is_set():
raise Cancelled()
if on_tick is not None:
try:
on_tick()
except Exception:
pass
j = self._req("GET", "/api/fs/job/status")
if not j.get("busy"):
if j.get("error"):
@@ -1270,7 +1275,7 @@ class BFpilot:
time.sleep(1)
raise PipeError("BFpilot tarda demasiado")
def delete(self, path, cancel=None):
def delete(self, path, cancel=None, on_tick=None):
for _ in range(60):
try:
self._req("POST", "/api/fs/delete", form={"path": path, "recursive": "1"})
@@ -1282,7 +1287,7 @@ class BFpilot:
if "No such file" in str(e) or "not found" in str(e).lower():
return
raise
self.wait_job(cancel)
self.wait_job(cancel, timeout=4 * 3600, on_tick=on_tick)
def du(self, path, timeout=20):
"""Tamano total de una carpeta (o archivo), o None."""
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+706 -101
View File
@@ -4,7 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PS5 Directo</title>
<link rel="icon" href="/favicon.ico">
<link rel="icon" href="/favicon.ico?v=1.18.2" sizes="any">
<link rel="icon" type="image/png" sizes="256x256" href="/icon-256.png?v=1.18.2">
<link rel="apple-touch-icon" href="/icon-192.png?v=1.18.2">
<link rel="manifest" href="/manifest.webmanifest">
<style>
:root{
--bg:#0e1116;--panel:#161b22;--panel2:#1c232c;--line:#2a323d;--text:#e6edf3;--muted:#8b97a5;
@@ -20,7 +23,7 @@ header .sub{color:var(--muted);font-size:13px}
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);font-size:12.5px;color:var(--muted);white-space:nowrap}
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
.dot.ok{background:var(--ok)}.dot.err{background:var(--err)}.dot.warn{background:var(--warn)}
main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;gap:18px}
main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;grid-template-columns:minmax(0,1fr);gap:18px}
.card{background:rgba(22,27,34,.86);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:var(--radius);padding:18px}
.card h2{font-size:15px;margin:0 0 12px;font-weight:600}
textarea,input,select{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit}
@@ -69,6 +72,8 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
.tabs button{background:transparent;border:1px solid transparent;border-radius:9px;padding:8px 14px;color:var(--muted);font-weight:600}
.tabs button:hover{color:var(--text);border-color:var(--line)}
.tabs button.on{color:var(--text);background:var(--panel2);border-color:var(--line)}
.tabs{overflow-x:auto;scrollbar-width:none}.tabs button{flex:none}
@media(max-width:600px){.tabs{padding:0 12px;gap:2px}.tabs button{padding:7px 9px;font-size:13.5px}}
.pickrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
/* --- explorador --- */
.fm-head{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
@@ -76,6 +81,7 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
.store{min-width:220px;text-align:right;font-size:13px;color:var(--muted)}
.store b{color:var(--text)}
.store .bar{margin-top:6px;height:6px}
@media(max-width:600px){.store{min-width:0;flex:1 1 100%;text-align:left}#tabFiles .fmtable{display:block;overflow-x:auto}}
.fm-bar{display:flex;gap:6px;align-items:center;margin-top:14px;flex-wrap:wrap}
.fm-bar input,.fm-bar select{width:auto}
.fm-bar #fmPath{flex:1;min-width:220px;font-family:Consolas,"Cascadia Mono",monospace;font-size:13px}
@@ -102,6 +108,109 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
.rowact{display:flex;gap:4px;justify-content:flex-end;opacity:.5}
tr:hover .rowact{opacity:1}
.rowact button{padding:3px 8px;font-size:12px}
.news-v{margin:14px 0 6px;font-size:14px;color:var(--accent)}
.news-list{margin:0;padding-left:20px;line-height:1.5}.news-list li{margin:6px 0}
.sp-grid{display:grid;grid-template-columns:280px 1fr;gap:22px;align-items:center;margin-top:14px}
@media(max-width:760px){.sp-grid{grid-template-columns:1fr}.sp-donut{margin:0 auto}}
.sp-donut{position:relative;width:280px;height:280px}
.sp-donut svg{display:block}
.sp-donut path{cursor:pointer;transition:opacity .15s}
.sp-donut.hl path:not(.on){opacity:.35}
.sp-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;pointer-events:none;padding:0 70px}
.sp-center b{font-size:22px}.sp-center span{color:var(--muted);font-size:12.5px}
.sp-legend{display:flex;flex-direction:column;gap:5px;font-size:13.5px}
.sp-legend .row{display:flex;align-items:center;gap:9px;padding:4px 6px;border-radius:7px;cursor:pointer}
.sp-legend .row:hover,.sp-legend .row.on{background:rgba(255,255,255,.05)}
.sp-legend .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sp-legend .sz{color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
.sw{width:12px;height:12px;border-radius:3px;flex:none;display:inline-block}
.sw.free{background:transparent;border:1.5px dashed #5b6573}
.sp-note{font-size:12px;color:var(--muted);margin-top:2px}
.sp-note.dup,.sp-note.bad{color:var(--warn)}
.sp-pct{height:5px;background:var(--bg);border-radius:4px;overflow:hidden;margin-top:4px;max-width:180px}
.sp-pct>i{display:block;height:100%;border-radius:4px}
.sp-tip{position:fixed;z-index:50;pointer-events:none;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12.5px;box-shadow:0 6px 20px rgba(0,0,0,.4);max-width:320px}
.sp-tools{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.sp-tools select{width:auto}
#spBody .mono{word-break:break-all}
.sp-grid>*{min-width:0}
#spListCard table{table-layout:fixed}
#spListCard th:nth-child(1){width:28px}#spListCard th:nth-child(3){width:90px}#spListCard th:nth-child(4){width:150px}
@media(max-width:600px){#spListCard th:nth-child(4){width:76px}#spListCard th:nth-child(3){width:72px}}
#spBody td{overflow-wrap:anywhere}
@media(max-width:600px){#spBody td:last-child button{display:block;width:100%;margin:3px 0}#spBody td:last-child{white-space:normal}}
.fm-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:18px;margin-top:14px}
.fm-side{display:flex;flex-direction:column;gap:2px;border-right:1px solid var(--line);padding-right:12px}
.side-h{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:10px 8px 4px}
.side-h:first-child{margin-top:0}
.side-i{display:flex;gap:9px;align-items:flex-start;text-align:left;background:none;border:1px solid transparent;border-radius:8px;padding:7px 8px;color:var(--text);font-size:13.5px;width:100%}
.side-i:hover{background:rgba(255,255,255,.04);border-color:transparent}
.side-i.on{background:rgba(61,139,253,.14);color:#dbe7ff}
.side-i svg{flex:none;margin-top:2px;color:var(--muted)}
.side-i>span{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
.side-i.small-i>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.side-i small{color:var(--muted);font-size:11.5px}
.side-i.small-i{font-size:12.5px;color:var(--muted);padding:5px 8px}
.mini{display:block;height:4px;border-radius:3px;background:var(--bg);overflow:hidden}
.mini b{display:block;height:100%;background:var(--accent)}
.fm-main{min-width:0}
.fm-main .fm-bar{margin-top:0}
.crumbs{flex:1;min-width:200px;display:flex;align-items:center;gap:2px;flex-wrap:wrap;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:3px 6px;min-height:36px;cursor:text}
.crumb{background:none;border:1px solid transparent;border-radius:6px;padding:3px 7px;font:13px Consolas,"Cascadia Mono",monospace;color:#bcd0ee}
.crumb:hover{background:rgba(255,255,255,.06);border-color:transparent}
.crumb.cur{color:var(--text);font-weight:600}
.csep{color:var(--muted);font-size:12px}
.selinfo{font-size:12.5px;color:var(--muted);margin-right:2px}
.fmtable tr.cut td{opacity:.55}
.fmtable th.sorted{color:var(--text)}
.dots{padding:2px 9px;font-size:15px;line-height:1;opacity:.6}
tr:hover .dots{opacity:1}
.fm-drop{position:relative;border-radius:10px}
.fm-drop.over{outline:2px dashed var(--accent);outline-offset:4px}
.fm-drop.over::after{content:attr(data-drop);position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(14,17,22,.75);color:var(--text);font-weight:600;border-radius:10px;pointer-events:none}
.keys{font-size:11.5px;opacity:.7}
.ctxmenu{position:fixed;z-index:60;min-width:210px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:5px;box-shadow:0 12px 32px rgba(0,0,0,.5)}
.ctxmenu button{display:flex;justify-content:space-between;gap:18px;width:100%;text-align:left;background:none;border:none;border-radius:6px;padding:7px 10px;font-size:13.5px}
.ctxmenu button:hover,.ctxmenu button:focus{background:rgba(61,139,253,.18);outline:none}
.ctxmenu button.danger{color:#ffb4ae}
.ctxmenu kbd{font:11.5px "Segoe UI",system-ui,sans-serif;color:var(--muted)}
.ctxmenu hr{border:none;border-top:1px solid var(--line);margin:4px 2px}
.ctxmenu hr:first-child,.ctxmenu hr:last-child,.ctxmenu hr+hr{display:none}
.pick-top{display:flex;gap:6px;align-items:center;margin-top:10px}
.pick-cols{display:grid;grid-template-columns:200px minmax(0,1fr);gap:12px;margin-top:10px;height:min(360px,48vh)}
.pick-side{overflow:auto;border-right:1px solid var(--line);padding-right:8px}
.pick-list{overflow:auto;border:1px solid var(--line);border-radius:8px;padding:4px}
.pick-i{display:flex;gap:9px;align-items:center;width:100%;text-align:left;background:none;border:none;border-radius:6px;padding:7px 9px;font-size:13.5px}
.pick-i:hover{background:rgba(255,255,255,.05)}
.pick-i[disabled]{opacity:.4;cursor:default}
.pick-foot{display:flex;gap:8px;align-items:center;margin-top:12px}
.cf-list{margin:8px 0 14px;padding:0;list-style:none;max-height:180px;overflow:auto}
.cf-list li{display:flex;gap:8px;align-items:center;padding:4px 0;font-size:13.5px}
.cf-opts{display:flex;gap:8px;flex-wrap:wrap}
#dlgConflict,#dlgExtract{width:min(520px,94vw)}
#dlgExtract input{margin-bottom:2px}
.bell{position:relative;padding:5px 9px}
.bell svg{display:block}
.bell.has{color:#fff}
.badge{position:absolute;top:-6px;right:-6px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--err);color:#fff;font-size:10.5px;font-weight:700;display:flex;align-items:center;justify-content:center}
.npanel{position:fixed;top:64px;right:16px;z-index:55;width:min(400px,calc(100vw - 32px));max-height:min(560px,75vh);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.55)}
.nhead{display:flex;gap:6px;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line)}
.nhead h2{margin:0;font-size:15px}
.nhead button{white-space:nowrap;padding:4px 8px;font-size:12px}
#nList{overflow:auto;padding:6px}
.nitem{display:flex;gap:10px;align-items:flex-start;padding:9px 10px;border-radius:8px}
.nitem.link{cursor:pointer}
.nitem:hover{background:rgba(255,255,255,.04)}
.nitem.fresh{background:rgba(61,139,253,.09)}
.ndot{flex:none;width:9px;height:9px;border-radius:50%;margin-top:5px;background:var(--muted)}
.nitem.ok .ndot{background:var(--ok)}.nitem.err .ndot{background:var(--err)}.nitem.warn .ndot{background:var(--warn)}.nitem.info .ndot{background:var(--accent)}
.nbody{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;font-size:13.5px}
.nbody span{color:var(--muted);font-size:12.5px;word-break:break-word}
.nbody small{color:var(--muted);font-size:11.5px;opacity:.8}
.nx{background:none;border:none;color:var(--muted);font-size:17px;padding:0 4px;line-height:1;opacity:0}
.nitem:hover .nx{opacity:1}
.job.flash{outline:2px solid var(--accent);outline-offset:2px}
@media(max-width:860px){.fm-layout{grid-template-columns:1fr}.fm-side{border-right:none;padding-right:0;flex-direction:row;flex-wrap:wrap;gap:4px}.side-h{display:none}.side-i{width:auto;padding:5px 9px;border:1px solid var(--line)}.side-i small,.side-i .mini{display:none}.pick-cols{grid-template-columns:1fr}.pick-side{display:none}}
.acttable{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:10px}
.acttable th{color:var(--muted);font-weight:600;font-size:12px;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
.acttable td{padding:8px;border-bottom:1px solid rgba(42,50,61,.6);vertical-align:top}
@@ -141,6 +250,7 @@ tr:hover .rowact{opacity:1}
<span class="pill" id="pFtp"><span class="dot"></span>FTP</span>
<span class="pill" id="pBf"><span class="dot"></span>BFpilot</span>
<span class="pill" id="pFree" hidden></span>
<button class="small bell" id="btnBell" title="Notificaciones"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg><span class="badge" id="bellN" hidden></span></button>
<button class="small" id="btnLang" data-noi18n title="Idioma / Language"></button>
<button class="small" id="btnTour" title="Guía rápida: para qué es cada cosa">Guía</button>
<button class="small" id="btnExtHead" title="Capturar enlaces desde el navegador sin copiar y pegar">Extensión</button>
@@ -148,9 +258,14 @@ tr:hover .rowact{opacity:1}
<button class="small" id="btnSettings">Ajustes</button>
<button class="small" id="btnQuit" title="Cierra PS5 Directo del todo">Salir</button>
</header>
<div class="npanel" id="nPanel" hidden>
<div class="nhead"><h2>Notificaciones</h2><div class="spacer"></div><button class="small" id="nAllRead">Marcar leídos</button><button class="small" id="nClear">Borrar todo</button></div>
<div id="nList"></div>
</div>
<nav class="tabs" id="tabs">
<button data-tab="Transfers" class="on">Transferencias</button>
<button data-tab="Files">Archivos de la consola</button>
<button data-tab="Space">Espacio</button>
<button data-tab="Activity">Actividad</button>
</nav>
@@ -206,42 +321,69 @@ tr:hover .rowact{opacity:1}
<div class="spacer"></div>
<div class="store" id="fmStore"></div>
</div>
<div class="fm-bar">
<button class="small iconbtn" id="fmBack" title="Atrás">←</button>
<button class="small iconbtn" id="fmFwd" title="Adelante">→</button>
<button class="small iconbtn" id="fmUp" title="Subir un nivel">↑</button>
<input id="fmPath" value="/data/homebrew" spellcheck="false">
<select id="fmPlaces" title="Ir a"><option value="">Ir a…</option></select>
<input id="fmSearch" placeholder="Buscar aquí…">
<button class="small iconbtn" id="fmRefresh" title="Actualizar">⟳</button>
<div class="fm-layout">
<aside class="fm-side" id="fmSide"></aside>
<div class="fm-main">
<div class="fm-bar">
<button class="small iconbtn" id="fmBack" title="Atrás (Alt+←)">←</button>
<button class="small iconbtn" id="fmFwd" title="Adelante (Alt+→)">→</button>
<button class="small iconbtn" id="fmUp" title="Subir un nivel (Retroceso)">↑</button>
<div class="crumbs" id="fmCrumbs" title="Haz clic en el hueco para escribir una ruta"></div>
<input id="fmPath" value="/data/homebrew" spellcheck="false" hidden>
<input id="fmSearch" placeholder="Buscar aquí…">
<button class="small iconbtn" id="fmRefresh" title="Actualizar (F5)">⟳</button>
</div>
<div class="fm-actions">
<button class="small" id="fmMkdir">Nueva carpeta</button>
<button class="small" id="fmUpFiles">Subir archivos aquí</button>
<button class="small" id="fmUpFolder">Subir carpeta aquí</button>
<span class="sep"></span>
<span class="selinfo" id="fmSelInfo"></span>
<button class="small" id="fmCopy" disabled title="Ctrl+C">Copiar</button>
<button class="small" id="fmCut" disabled title="Ctrl+X">Cortar</button>
<button class="small" id="fmMoveTo" disabled>Mover a…</button>
<button class="small" id="fmRename" disabled title="F2">Renombrar</button>
<button class="small" id="fmDownload" disabled>Descargar al PC</button>
<button class="small" id="fmMore" disabled>Más…</button>
<button class="small danger" id="fmDelete" disabled title="Supr">Borrar</button>
</div>
<div class="clip" id="fmClip" hidden></div>
<div class="fmop" id="fmOp" hidden></div>
<div class="fm-drop" id="fmTableWrap">
<table class="fmtable">
<thead><tr>
<th style="width:28px"><input type="checkbox" id="fmAll"></th>
<th data-sort="name">Nombre</th>
<th data-sort="size" class="num">Tamaño</th>
<th data-sort="mtime" class="hide-s">Modificado</th>
<th class="hide-s">Permisos</th>
<th style="width:40px"></th>
</tr></thead>
<tbody id="fmBody"></tbody>
</table>
</div>
<p class="hint" id="fmHint" style="margin-top:10px"></p>
<p class="hint keys">Atajos: doble clic abre · clic derecho para el menú · Supr borra · F2 renombra · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selecciona todo · Retroceso sube</p>
</div>
</div>
<div class="fm-actions">
<button class="small" id="fmMkdir">Nueva carpeta</button>
<button class="small" id="fmUpFiles">Subir archivos aquí</button>
<button class="small" id="fmUpFolder">Subir carpeta aquí</button>
<span class="sep"></span>
<button class="small" id="fmCopy" disabled>Copiar</button>
<button class="small" id="fmCut" disabled>Mover</button>
<button class="small" id="fmRename" disabled>Renombrar</button>
<button class="small" id="fmDownload" disabled>Descargar al PC</button>
<button class="small" id="fmSize" disabled>Calcular tamaño</button>
<button class="small" id="fmPerms" disabled>Permisos 777</button>
<button class="small danger" id="fmDelete" disabled>Borrar</button>
</div>
<div class="clip" id="fmClip" hidden></div>
<div class="fmop" id="fmOp" hidden></div>
<table class="fmtable">
<thead><tr>
<th style="width:28px"><input type="checkbox" id="fmAll"></th>
<th data-sort="name">Nombre</th>
<th data-sort="size" class="num">Tamaño</th>
<th data-sort="mtime" class="hide-s">Modificado</th>
<th class="hide-s">Permisos</th>
<th></th>
</tr></thead>
<tbody id="fmBody"></tbody>
</table>
<p class="hint" id="fmHint" style="margin-top:10px"></p>
</section>
</main>
<main id="tabSpace" hidden>
<section class="card">
<div class="fm-head"><h2>Espacio</h2><div class="spacer"></div>
<div class="sp-tools"><select id="spRoot"><option value="/data">Almacenamiento interno</option></select>
<button class="primary small" id="spScan">Analizar</button></div></div>
<p class="hint">Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.</p>
<div id="spProg" hidden><div class="phase" id="spPhase"></div><div class="bar"><i id="spBar"></i></div>
<button class="small" id="spCancel" style="margin-top:8px">Cancelar</button></div>
<div id="spOut"></div>
</section>
<section class="card" id="spListCard" hidden>
<div class="fm-head"><h2>Todo lo que ocupa sitio</h2><div class="spacer"></div>
<button class="small" id="spDel" disabled>Borrar seleccionados</button></div>
<table class="fmtable"><thead><tr><th style="width:28px"></th><th>Nombre</th><th class="num">Tamaño</th><th></th></tr></thead>
<tbody id="spBody"></tbody></table>
</section>
</main>
@@ -254,6 +396,50 @@ tr:hover .rowact{opacity:1}
</section>
</main>
<div class="ctxmenu" id="fmMenu" hidden></div>
<dialog id="dlgPick">
<div class="head"><h2 id="pickTitle">Mover a…</h2><div class="spacer"></div><button class="small" id="pickX">Cerrar</button></div>
<div class="body">
<div class="pick-top"><button class="small iconbtn" id="pickUp" title="Subir un nivel">↑</button><div class="crumbs" id="pickCrumbs"></div></div>
<div class="pick-cols">
<div class="pick-side" id="pickSide"></div>
<div class="pick-list" id="pickList"></div>
</div>
<p class="hint" id="pickNote"></p>
<div class="pick-foot"><button class="small" id="pickNew">Nueva carpeta</button><div class="spacer"></div><button class="primary" id="pickGo">Mover aquí</button></div>
</div>
</dialog>
<dialog id="dlgConflict">
<div class="head"><h2 id="cfTitle">Ya existe</h2></div>
<div class="body">
<p id="cfBody"></p>
<ul class="cf-list" id="cfList" data-noi18n></ul>
<div class="cf-opts">
<button class="primary" data-m="replace" id="cfReplace">Reemplazar / combinar</button>
<button data-m="keepboth">Conservar ambos</button>
<button data-m="skip">Omitir</button>
<button data-m="">Cancelar</button>
</div>
<p class="hint" id="cfHint">Reemplazar sobrescribe los archivos con el mismo nombre. Si es una carpeta, se combina: lo que ya tenía y no estás pegando se queda. Conservar ambos le pone (2) al nuevo.</p>
</div>
</dialog>
<dialog id="dlgExtract">
<div class="head"><h2 id="exTitle">Extraer</h2></div>
<div class="body">
<p class="hint">La consola lo descomprime en una carpeta al lado del archivo (con BFpilot) y deja el comprimido como está. Nada pasa por el PC.</p>
<label>Carpeta</label><input id="exFolder" spellcheck="false">
<label style="margin-top:10px">Contraseña (solo si tiene)</label><input id="exPw" spellcheck="false" autocomplete="off">
<p class="hint" id="exNote"></p>
<div class="pick-foot"><div class="spacer"></div><button id="exCancel">Cancelar</button><button class="primary" id="exGo">Extraer</button></div>
</div>
</dialog>
<dialog id="dlgNews">
<div class="head"><h2 id="newsTitle">Novedades</h2><div class="spacer"></div></div>
<div class="body"><div id="newsBody"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px"><button class="primary" id="btnNewsOk">Entendido</button></div></div>
</dialog>
<dialog id="dlgSpeed">
<div class="head"><h2>Test de velocidad</h2><div class="spacer"></div><button class="small" id="btnSpeedClose">Cerrar</button></div>
<div class="body">
@@ -344,8 +530,8 @@ tr:hover .rowact{opacity:1}
<div class="toast" id="toast"></div>
<script>
const I18N_EXACT={"Envía archivos a tu consola por la red local, sin pasar por el disco del PC": "Send files to your console over your local network, without going through your PC's disk", "Guía": "Guide", "Guía rápida: para qué es cada cosa": "Quick guide: what each part does", "Extensión": "Extension", "Capturar enlaces desde el navegador sin copiar y pegar": "Capture links from your browser without copy and paste", "Actualizar": "Update", "Buscar o instalar una versión nueva": "Check for or install a new version", "Ajustes": "Settings", "Salir": "Quit", "Cierra PS5 Directo del todo": "Closes PS5 Directo completely", "Nueva transferencia": "New transfer", "Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…": "Direct links (one per line). Paste all parts together: .part1.rar, .part2.rar… or .001, .002…", "https://servidor/archivo.part1.rar": "https://server/file.part1.rar", "https://servidor/archivo.part2.rar": "https://server/file.part2.rar", "También vale la ruta de un archivo de tu PC, por ejemplo: D:\\Descargas\\archivo.rar": "A file path on your PC also works, for example: D:\\Downloads\\file.rar", "Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el": "Files on your PC: drop them on this page and I'll find them on your PC, or drag them onto the", "icono de PS5 Directo": "PS5 Directo icon", ", o clic derecho →": ", or right-click →", "Enviar a → PS5 Directo": "Send to → PS5 Directo", "Contraseña del comprimido (si la tiene)": "Archive password (if any)", "Opcional. Si la dejas vacía prueba las de Ajustes y las del nombre": "Optional. If empty, it tries the ones in Settings and the one in the file name", "Destino en la PS5": "Destination on the PS5", "Dónde descomprimir": "Where to extract", "En la PS5 (BFpilot)": "On the PS5 (BFpilot)", "En el PC (7-Zip)": "On the PC (7-Zip)", "Añadir a la cola": "Add to queue", "Si pegas enlaces de varios archivos distintos, los separa solo. «En el PC» usa 7-Zip y manda el contenido ya descomprimido y comprobado (más datos por el Wi-Fi, pero sin BFpilot).": "If you paste links to several different files, it splits them automatically. \"On the PC\" uses 7-Zip and sends the content already extracted and verified (more data over Wi-Fi, but no BFpilot needed).", "Cola": "Queue", "Quitar terminados": "Clear finished", "No hay nada en la cola. Pega enlaces arriba.": "The queue is empty. Paste links above.", "Transferencia": "Transfer", "Pausar": "Pause", "Reanudar": "Resume", "Cancelar": "Cancel", "Reintentar": "Retry", "Quitar": "Remove", "Test de velocidad": "Speed test", "Mide descarga, envío y las dos cosas a la vez": "Measures download, upload and both at once", "Bajar al PC": "Download to PC", "Descomprimir y enviar": "Extract and send", "Colocar": "Place", "Listo": "Done", "Enviar": "Send", "Descomprimir": "Extract", "Enviado": "Sent", "de": "of", "Velocidad a la PS5": "Speed to PS5", "Quedan": "Remaining", "Descompresión en la PS5": "Extraction on the PS5", "Archivos y registro": "Files and log", "Copiar registro": "Copy log", "Registro copiado. Pégalo donde quieras (Ctrl+V).": "Log copied. Paste it anywhere (Ctrl+V).", "Registro copiado.": "Log copied.", "Contraseña del comprimido (vacío = probar las guardadas):": "Archive password (empty = try the saved ones):", "¿Cancelar esta transferencia? Lo ya enviado se conserva y se puede reintentar.": "Cancel this transfer? What was already sent is kept and you can retry.", "Añadido a la cola": "Added to queue", "pendiente": "pending", "comprobando": "checking", "enviando": "sending", "en cola para descomprimir": "waiting to extract", "extrayendo": "extracting", "colocando": "placing", "hecho": "done", "error": "error", "cancelado": "cancelled", "pausado": "paused", "Mide por separado la descarga desde el enlace, el envío del PC a la PS5 y las dos cosas a la vez (unos 30 segundos). Así se sabe de quién es la culpa cuando va lento. No se guarda nada: el archivo de prueba de la PS5 se borra al acabar.": "Measures separately the download from the link, the upload from PC to PS5, and both at once (about 30 seconds), so you know what's to blame when it's slow. Nothing is kept: the test file on the PS5 is deleted at the end.", "Cerrar": "Close", "Midiendo…": "Measuring…", "Descarga sola": "Download only", "PC → PS5 solo": "PC → PS5 only", "Las dos a la vez: descarga / envío": "Both at once: download / upload", "Llegaría a la PS5 descargando y enviando": "Would reach the PS5 downloading and sending", "IP de la PS5": "PS5 IP address", "por ejemplo 192.168.1.50": "for example 192.168.1.50", "Destino por defecto": "Default destination", "Puerto FTP": "FTP port", "Usuario FTP": "FTP user", "Contraseña FTP": "FTP password", "(vacía)": "(empty)", "Puerto BFpilot": "BFpilot port", "Conexiones por archivo": "Connections per file", "Partes a la vez (1-4)": "Parts at once (1-4)", "Trozo (MB)": "Line truncated
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are in, click \"Add to queue\"."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo enviado esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was sent is in {1}"], ["^Lo usa \"([\\s\\S]*?)\" de la cola: si lo borras, esa transferencia tendrá que enviarse otra vez\\.$", "Used by \"{0}\" in the queue: if you delete it, that transfer will have to be sent again."], ["^No encuentro en la PS5 lo enviado \\(([\\s\\S]*?)\\)\\. Pulsa ReintentaLine truncated
const I18N_EXACT={"Atrás (Alt+←)": "Back (Alt+←)", "Adelante (Alt+→)": "Forward (Alt+→)", "Subir un nivel (Retroceso)": "Up one level (Backspace)", "Haz clic en el hueco para escribir una ruta": "Click the empty space to type a path", "Actualizar (F5)": "Refresh (F5)", "Cortar": "Cut", "Mover a…": "Move to…", "Copiar a…": "Copy to…", "Más…": "More…", "Más acciones": "More actions", "Atajos: doble clic abre · clic derecho para el menú · Supr borra · F2 renombra · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selecciona todo · Retroceso sube": "Shortcuts: double-click opens · right-click for the menu · Del deletes · F2 renames · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selects all · Backspace goes up", "Mover aquí": "Move here", "Copiar aquí": "Copy here", "Nueva carpeta": "New folder", "Ya existe": "Already exists", "Reemplazar / combinar": "Replace / merge", "Reemplazar": "Replace", "Combinar": "Merge", "Conservar ambos": "Keep both", "Omitir": "Skip", "Reemplazar sobrescribe los archivos con el mismo nombre. Si es una carpeta, se combina: lo que ya tenía y no estás pegando se queda. Conservar ambos le pone (2) al nuevo.": "Replace overwrites files with the same name. Folders are merged: whatever they already had that you are not pasting stays. Keep both adds (2) to the new one.", "¿Qué hago con los que ya existen en la carpeta de destino?": "What should I do with the ones that already exist in the destination folder?", "Extraer": "Extract", "Extraer…": "Extract…", "Carpeta": "Folder", "Contraseña (solo si tiene)": "Password (only if it has one)", "La consola lo descomprime en una carpeta al lado del archivo (con BFpilot) y deja el comprimido como está. Nada pasa por el PC.": "The console extracts it into a folder next to the archive (with BFpilot) and leaves the archive as it is. Nothing goes through the PC.", "Notificaciones": "Notifications", "Marcar leídos": "Mark all read", "Borrar todo": "Clear all", "Descartar": "Dismiss", "Unidades": "Drives", "Accesos": "Places", "Recientes": "Recent", "Destinos recientes": "Recent destinations", "libres": "free", "Almacenamiento interno": "Internal storage", "Aplicaciones (homebrew)": "Apps (homebrew)", "Datos (/data)": "Data (/data)", "Paquetes (/data/pkg)": "Packages (/data/pkg)", "Restos de PS5 Directo": "PS5 Directo leftovers", "BFpilot no está cargado: el explorador va por FTP. Copiar pasa por el PC (más lento), y para descomprimir o ver el espacio libre hace falta BFpilot.": "BFpilot is not loaded: the browser works over FTP. Copying goes through the PC (slower), and extracting or showing free space needs BFpilot.", "desde": "from", "Pegar dentro": "Paste inside", "Pegar aquí": "Paste here", "Subir archivos aquí": "Upload files here", "Subir carpeta aquí": "Upload folder here", "Actualizar la carpeta": "Refresh folder", "Supr": "Del", "Copiar ruta de esta carpeta": "Copy this folder's path", "Abrir": "Open", "Descargar al PC": "Download to PC", "Copiar": "Copy", "Renombrar": "Rename", "Calcular tamaño": "Calculate size", "Permisos 777": "Permissions 777", "Copiar ruta": "Copy path", "Copiar rutas": "Copy paths", "Borrar": "Delete", "Ve a la carpeta de destino y pulsa \"Pegar aquí\" (o Ctrl+V)": "Go to the destination folder and click \"Paste here\" (or Ctrl+V)", "Ruta copiada": "Path copied", "El nombre no puede llevar / ni \\": "The name cannot contain / or \\", "No se puede pegar una carpeta dentro de sí misma": "You cannot paste a folder inside itself", "No hay carpetas dentro.": "No folders inside.", "Una carpeta no se puede meter dentro de sí misma.": "A folder cannot go inside itself.", "Ya está en esta carpeta.": "It is already in this folder.", "Otra unidad sin BFpilot: se copia pasando por el PC y luego se borra el original.": "Another drive without BFpilot: it is copied through the PC and then the original is deleted.", "BFpilot no está cargado: cárgalo en la PS5 para poder extraer.": "BFpilot is not loaded: load it on the PS5 to extract.", "Ya hay una carpeta con ese nombre: se usará uno nuevo terminado en (2).": "There is already a folder with that name: a new one ending in (2) will be used.", "Pon un solo nombre de carpeta, sin /": "Use a single folder name, without /", "No los encuentro en tu PC: usa \"Subir archivos aquí\"": "I cannot find them on your PC: use \"Upload files here\"", "Extrayendo": "Extracting", "ahora": "now", "Aún no hay avisos. Aquí aparecerán las transferencias terminadas, los errores, las desconexiones y las versiones nuevas.": "No notifications yet. Finished transfers, errors, disconnections and new versions will show up here.", "Novedades": "What's new", "Entendido": "Got it", "Se ha actualizado. Esto es lo nuevo:": "PS5 Directo has been updated. Here is what's new:", "Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa Line truncated
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
// --- idioma: español (original) o inglés. Traduce el texto de la página y los mensajes del programa.
const LANG=(()=>{try{const v=localStorage.getItem('ps5d_lang');if(v==='es'||v==='en')return v}catch(e){}
return (navigator.language||'es').toLowerCase().startsWith('es')?'es':'en'})();
@@ -410,7 +596,18 @@ let state = {jobs:[], settings:{}};
let openLogs = new Set();
function toast(msg){const t=$('toast');t.textContent=msg;t.classList.add('show');clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove('show'),3500)}
// Consultas que se pueden repetir sin efectos: si falla la conexion un instante, se reintenta
const SAFE_API=new Set(['/api/state','/api/test','/api/settings','/api/places','/api/whatsnew','/api/space/state']);
async function api(path, body){
for(let i=0;;i++){
try{ return await api1(path, body) }
catch(e){
if(!(e instanceof TypeError) || i>=2 || !(body===undefined || SAFE_API.has(path))) throw e;
await new Promise(r=>setTimeout(r, 400*(i+1)));
}
}
}
async function api1(path, body){
const r = await fetch(path,{method: body===undefined?'GET':'POST',headers:{'X-Token':TOKEN,'X-UI':'1','Content-Type':'application/json'},body: body===undefined?undefined:JSON.stringify(body)});
const j = await r.json().catch(()=>({ok:false,error:'HTTP '+r.status}));
if(!r.ok || j.ok===false) throw new Error(j.error||('HTTP '+r.status));
@@ -538,9 +735,11 @@ function paintConn(r){
}
async function checkConn(){try{paintConn(await api('/api/test',{}))}catch(e){}}
let refreshFails=0, refreshing=false;
async function refresh(){
try{const s=await api('/api/state');state=s;if(s.version){$('ver').textContent='v'+s.version;document.title='PS5 Directo v'+s.version}if(s.inbox>0)takeInbox();if(!$('dest').dataset.touched)$('dest').value=s.settings.dest||'/data/homebrew';if(!$('where').dataset.touched)$('where').value=s.settings.extract_where==='pc'?'pc':'ps5';render()}
catch(e){$('jobs').innerHTML=`<div class="empty">No conecto con el programa. ¿Cerraste la ventana negra? (${esc(e.message)})</div>`}
if(refreshing) return; refreshing=true;
try{const s=await api('/api/state');refreshFails=0;state=s;paintBell(s.unread||0);if(s.version){$('ver').textContent='v'+s.version;document.title='PS5 Directo v'+s.version}if(s.inbox>0)takeInbox();if(!$('dest').dataset.touched)$('dest').value=s.settings.dest||'/data/homebrew';if(!$('where').dataset.touched)$('where').value=s.settings.extract_where==='pc'?'pc':'ps5';render()}
catch(e){ if(++refreshFails<5) return; $('jobs').innerHTML=`<div class="empty">No conecto con el programa. Cierra esta ventana y abre PS5 Directo otra vez. (${esc(e.message)})</div>`}
}
$('dest').addEventListener('input',()=>$('dest').dataset.touched='1');
$('where').addEventListener('change',()=>$('where').dataset.touched='1');
@@ -557,7 +756,7 @@ const TOUR=[
{t:'#btnExtHead',h:'Extensión del navegador',p:'Opcional. Con ella, al pulsar Descargar en una web, el enlace llega aquí directo, sin copiar y pegar. Este botón abre su carpeta y las instrucciones.'},
{t:'#btnUpdHead',h:'Actualizar',p:'Busca la versión nueva e instálala con un clic. Guarda una copia de la anterior por si acaso.'},
{t:'#btnSettings',h:'Ajustes',p:'Lo primero: la IP de tu PS5. Ahí están también el límite de velocidad, el espacio que dejar libre, "Revisión completa" (comprueba que todo funciona), "Reparar permisos" y "Limpieza".'},
{t:'#tabs',h:'Pestañas',p:'Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Actividad guarda el historial.'},
{t:'#tabs',h:'Pestañas',p:'Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa más y qué puedes borrar. Actividad guarda el historial.'},
{t:'#btnTour',h:'¡Listo!',p:'Puedes volver a ver esta guía cuando quieras con este botón. Ahora vamos a poner la IP de tu PS5.'},
];
let tourI=0, tourEls=null, tourDone=null;
@@ -617,10 +816,14 @@ $('btnLang').onclick=()=>setLang(LANG==='en'?'es':'en');
document.addEventListener('keydown',e=>{ if(!tourEls) return;
if(e.key==='Escape') tourEnd(); else if(e.key==='ArrowRight'||e.key==='Enter'){e.preventDefault();tourI===TOUR.length-1?tourEnd():tourGo(tourI+1)} else if(e.key==='ArrowLeft') tourGo(tourI-1)});
refresh().then(()=>{
if(state.settings && !state.settings.tour_done) startTour(needIp); else needIp();
if(state.settings && !state.settings.tour_done) startTour(needIp); else { needIp(); showNews(); }
checkUpdateQuiet();
}); checkConn();
setInterval(refresh, 1000); setInterval(checkConn, 20000);
// Avisar al programa cuando se cierra la ventana (minimizarla no cuenta)
addEventListener('pagehide',()=>{try{navigator.sendBeacon('/api/bye?t='+encodeURIComponent(TOKEN))}catch(e){}});
// Al volver a la ventana, ponerse al dia enseguida
document.addEventListener('visibilitychange',()=>{if(!document.hidden){refresh();checkConn();if(curTab==='Files'&&fm.loaded)fmGo(fm.path,true)}});
</script>
<script>
['dragover','drop'].forEach(ev=>window.addEventListener(ev,async e=>{
@@ -803,11 +1006,12 @@ function afterUpdate(v){
let curTab='Transfers';
function showTab(t){
curTab=t;
['Transfers','Files','Activity'].forEach(x=>{$('tab'+x).hidden=(x!==t)});
['Transfers','Files','Space','Activity'].forEach(x=>{$('tab'+x).hidden=(x!==t)});
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===t));
try{localStorage.setItem('ps5d_tab',t)}catch(e){}
if(t==='Files'&&!fm.loaded) fmGo(fm.path,true);
if(t==='Activity') loadActivity();
if(t==='Space') spEnter();
}
document.querySelectorAll('#tabs button').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
@@ -822,143 +1026,544 @@ async function pick(kind){
$('btnPickFiles').onclick=async()=>{const ps=await pick('files'); if(ps.length){const ta=$('lines');ta.value=(ta.value.trim()?ta.value.trim()+'\n':'')+ps.join('\n');toast(tr('Añadidas {0} ruta(s). Revisa destino y pulsa "Añadir a la cola".').replace('{0}',ps.length))}};
$('btnPickFolder').onclick=async()=>{const ps=await pick('folder'); if(ps.length){const ta=$('lines');ta.value=(ta.value.trim()?ta.value.trim()+'\n':'')+ps.join('\n');toast(tr('Añadidas {0} ruta(s). Revisa destino y pulsa "Añadir a la cola".').replace('{0}',ps.length))}};
// ===================== novedades tras actualizar =====================
async function showNews(){
let r; try{r=await api('/api/whatsnew',{lang:LANG})}catch(e){return}
if(!r.ok||!r.show) return;
$('newsTitle').textContent=tr('Novedades de PS5 Directo {0}').replace('{0}',r.version);
$('newsBody').innerHTML=`<p class="hint">${esc(tr('Se ha actualizado. Esto es lo nuevo:'))}</p>`+r.entries.map(e=>
`<div class="news-v">${esc(tr('Versión {0}').replace('{0}',e.version))}</div><ul class="news-list" data-noi18n>${e.items.map(i=>`<li>${esc(i)}</li>`).join('')}</ul>`).join('');
$('dlgNews').showModal();
}
$('btnNewsOk').onclick=()=>{$('dlgNews').close()};
$('dlgNews').addEventListener('close',()=>api('/api/whatsnew/seen',{}).catch(()=>{}));
// ===================== espacio en la consola =====================
const SP_COL=['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'];
const SP_REST='#56606d', SP_SYS='#3f4955', SP_INST='#6f7a87';
const sp={res:null,slices:[],sel:new Set(),poll:null,drives:false,hl:-1};
// nombre para ensenar: el titulo del juego y, si es una imagen, su tipo (para distinguir duplicados)
function spName(x){const ext=(x.kind==='image'&&/\.([a-z0-9]+)$/i.exec(x.name)); return (x.title||tr(x.name))+(ext&&x.title?' · '+ext[1].toLowerCase():'')}
async function spDrives(){
if(sp.drives) return; sp.drives=true;
try{const r=await api('/api/places',{}); const roots=[];
(r.dests||[]).forEach(d=>{const m=d.match(/^\/mnt\/(usb|ext)\d+/); if(m&&!roots.includes(m[0]))roots.push(m[0])});
$('spRoot').innerHTML=`<option value="/data">${esc(tr('Almacenamiento interno'))}</option>`+roots.map(x=>`<option value="${esc(x)}">${esc(tr('Disco externo'))} ${esc(x)}</option>`).join('');
}catch(e){}
}
function spEnter(){
spDrives();
spPoll().then(st=>{ if(st && !st.busy && !st.result && !st.error) spScan(); });
}
async function spScan(){
const r=await api('/api/space/scan',{root:$('spRoot').value});
if(!r.ok){toast(r.error);return}
sp.sel.clear(); spWatch();
}
function spWatch(){clearInterval(sp.poll); sp.poll=setInterval(spPoll,800); spPoll()}
async function spPoll(){
let r; try{r=await api('/api/space/state',{})}catch(e){return null}
const st=r.space;
$('spProg').hidden=!st.busy; $('spScan').disabled=st.busy;
if(st.busy){
$('spPhase').textContent=tr('Midiendo {0} de {1}: {2}').replace('{0}',st.done+1).replace('{1}',st.total||'?').replace('{2}',st.current||'');
$('spBar').style.width=(st.total?st.done*100/st.total:3)+'%';
return st;
}
clearInterval(sp.poll);
if(st.error){$('spOut').innerHTML=`<p class="err">${esc(st.error)}</p>`;return st}
if(st.result && st.result!==sp.res){ if(!sp.res||st.result.t!==sp.res.t){sp.res=st.result; spRender()} }
return st;
}
function spSlices(r){
const items=r.items.filter(x=>x.kind!=='system'&&x.size>0), sys=r.items.filter(x=>x.kind==='system'&&x.size>0);
const out=[]; items.slice(0,8).forEach((x,i)=>out.push({name:spName(x),size:x.size,color:SP_COL[i],item:x}));
const rest=items.slice(8); const restSz=rest.reduce((a,x)=>a+x.size,0);
if(restSz>0) out.push({name:tr('Resto ({0} elementos)').replace('{0}',rest.length),size:restSz,color:SP_REST});
sys.forEach(x=>out.push({name:tr(x.name),size:x.size,color:SP_INST,item:x}));
if(r.other>0) out.push({name:r.root==='/data'?tr('Sistema y otros datos de la consola'):tr('Otros'),size:r.other,color:SP_SYS});
if(r.free>0) out.push({name:tr('Libre'),size:r.free,color:'free'});
return out;
}
function spArc(cx,cy,R,r,a0,a1){
const p=(a,rad)=>[cx+rad*Math.sin(a),cy-rad*Math.cos(a)], big=(a1-a0)>Math.PI?1:0;
if(a1-a0>=Math.PI*2-1e-6) a1=a0+Math.PI*2-1e-4;
const [x0,y0]=p(a0,R),[x1,y1]=p(a1,R),[x2,y2]=p(a1,r),[x3,y3]=p(a0,r);
return `M${x0} ${y0}A${R} ${R} 0 ${big} 1 ${x1} ${y1}L${x2} ${y2}A${r} ${r} 0 ${big} 0 ${x3} ${y3}Z`;
}
function spCenter(i){
const r=sp.res, c=$('spCenter'); if(!c) return;
if(i<0||!sp.slices[i]){c.innerHTML=`<b>${esc(human(r.free))}</b><span>${esc(tr('libres de {0}').replace('{0}',human(r.total)))}</span>`;return}
const s=sp.slices[i]; c.innerHTML=`<b>${esc(human(s.size))}</b><span>${esc(s.name)}<br>${(s.size*100/Math.max(1,r.total)).toFixed(1)}%</span>`;
}
function spHL(i){
sp.hl=i; const d=document.querySelector('.sp-donut');
if(d){d.classList.toggle('hl',i>=0); d.querySelectorAll('path').forEach((p,k)=>p.classList.toggle('on',k===i))}
document.querySelectorAll('.sp-legend .row').forEach((row,k)=>row.classList.toggle('on',k===i));
spCenter(i);
}
function spRender(){
const r=sp.res; sp.slices=spSlices(r);
const tot=Math.max(1,sp.slices.reduce((a,x)=>a+x.size,0));
let a=0; const paths=sp.slices.map((s,i)=>{const a0=a, a1=a+s.size/tot*Math.PI*2; a=a1;
const free=s.color==='free';
return `<path d="${spArc(140,140,132,86,a0,a1)}" fill="${free?'#1c232c':s.color}" stroke="${free?'#5b6573':'#161b22'}" stroke-width="2" ${free?'stroke-dasharray="4 3"':''} data-i="${i}"></path>`}).join('');
const dups=r.items.filter(x=>x.dup).length, bad=r.items.filter(x=>x.kind==='incomplete'||x.kind==='leftover').length;
let warn='';
if(dups) warn+=`<p class="sp-note dup">⚠ ${esc(tr('Hay {0} elementos repetidos (mismo juego dos veces): revisa la lista.').replace('{0}',dups))}</p>`;
if(bad) warn+=`<p class="sp-note bad">⚠ ${esc(tr('Hay {0} envíos a medias o restos que puedes borrar.').replace('{0}',bad))}</p>`;
$('spOut').innerHTML=`<div class="sp-grid">
<div class="sp-donut"><svg width="280" height="280" viewBox="0 0 280 280" role="img" aria-label="${esc(tr('Espacio'))}">${paths}</svg><div class="sp-center" id="spCenter"></div></div>
<div><div class="sp-legend">${sp.slices.map((s,i)=>`<div class="row" data-i="${i}"><span class="sw ${s.color==='free'?'free':''}" style="${s.color==='free'?'':'background:'+s.color}"></span><span class="nm" data-noi18n>${esc(s.name)}</span><span class="sz">${esc(human(s.size))} · ${(s.size*100/Math.max(1,r.total)).toFixed(1)}%</span></div>`).join('')}</div>
<p class="hint" style="margin-top:10px">${esc(tr('Usado {0} de {1}. Analizado: {2}').replace('{0}',human(r.used)).replace('{1}',human(r.total)).replace('{2}',r.root))}</p>${warn}</div></div>`;
spCenter(-1);
const tip=$('spTip')||Object.assign(document.body.appendChild(document.createElement('div')),{id:'spTip',className:'sp-tip',hidden:true});
document.querySelectorAll('.sp-donut path').forEach(p=>{
const i=+p.dataset.i, s=sp.slices[i];
p.onmouseenter=()=>spHL(i); p.onmouseleave=()=>{spHL(-1);tip.hidden=true};
p.onmousemove=ev=>{tip.hidden=false; tip.innerHTML=`<b data-noi18n>${esc(s.name)}</b><br>${esc(human(s.size))} · ${(s.size*100/Math.max(1,r.total)).toFixed(1)}%${s.item?`<br><span class="hint" data-noi18n>${esc(s.item.path)}</span>`:''}`;
tip.style.left=Math.min(innerWidth-330,ev.clientX+14)+'px'; tip.style.top=(ev.clientY+14)+'px'};
p.onclick=()=>{ if(s.item){const row=document.querySelector(`#spBody tr[data-p="${CSS.escape(s.item.path)}"]`); if(row){row.scrollIntoView({block:'center',behavior:'smooth'});row.classList.add('sel');setTimeout(()=>row.classList.remove('sel'),1500)}} };
});
document.querySelectorAll('.sp-legend .row').forEach(row=>{const i=+row.dataset.i; row.onmouseenter=()=>spHL(i); row.onmouseleave=()=>spHL(-1);
row.onclick=()=>document.querySelectorAll('.sp-donut path')[i]?.onclick()});
spTable();
}
function spTable(){
const r=sp.res, col={}; sp.slices.forEach(s=>{if(s.item&&s.color!=='free')col[s.item.path]=s.color});
$('spListCard').hidden=false;
$('spBody').innerHTML=r.items.filter(x=>x.size>0||x.kind==='incomplete').map(x=>{
const pct=x.size*100/Math.max(1,r.total);
const notes=(x.notes||[]).map(n=>`<div class="sp-note ${/^Duplicado/.test(n)?'dup':(x.kind==='incomplete'||x.kind==='leftover')?'bad':''}">${esc(tr(n))}</div>`).join('');
const sw=col[x.path]?`<span class="sw" style="background:${col[x.path]}"></span> `:'';
return `<tr data-p="${esc(x.path)}" class="${sp.sel.has(x.path)?'sel':''}">
<td>${x.deletable?`<input type="checkbox" class="spck" ${sp.sel.has(x.path)?'checked':''}>`:''}</td>
<td><div>${sw}<b data-noi18n>${esc(spName(x))}</b></div><div class="hint mono" data-noi18n style="font-family:Consolas,monospace">${esc(x.path)}</div>${notes}
<div class="sp-pct"><i style="width:${Math.max(.5,Math.min(100,pct)).toFixed(1)}%;background:${col[x.path]||SP_REST}"></i></div></td>
<td class="num">${esc(human(x.size))}<div class="hint">${pct.toFixed(1)}%</div></td>
<td class="num">${x.kind!=='system'?`<button class="small spopen">${esc(tr('Abrir'))}</button> `:''}${x.deletable?`<button class="small spdel">${esc(tr('Borrar'))}</button>`:''}</td></tr>`}).join('');
$('spBody').querySelectorAll('tr').forEach(tr_=>{const p=tr_.dataset.p;
const ck=tr_.querySelector('.spck'); if(ck) ck.onchange=()=>{ck.checked?sp.sel.add(p):sp.sel.delete(p); tr_.classList.toggle('sel',ck.checked); spSelInfo()};
const o=tr_.querySelector('.spopen'); if(o) o.onclick=()=>{const it=sp.res.items.find(x=>x.path===p); showTab('Files'); fmGo(it&&it.dir?p:parentOf(p))};
const d=tr_.querySelector('.spdel'); if(d) d.onclick=()=>spDelete([p]);
});
spSelInfo();
}
function spSelInfo(){
const sz=sp.res?sp.res.items.filter(x=>sp.sel.has(x.path)).reduce((a,x)=>a+x.size,0):0;
$('spDel').disabled=!sp.sel.size;
$('spDel').textContent=sp.sel.size?tr('Borrar seleccionados ({0}, {1})').replace('{0}',sp.sel.size).replace('{1}',human(sz)):tr('Borrar seleccionados');
}
async function spDelete(paths){
const its=sp.res.items.filter(x=>paths.includes(x.path)), sz=its.reduce((a,x)=>a+x.size,0);
const list=its.slice(0,12).map(x=>'• '+(x.title?x.title+' — ':'')+x.path+' ('+human(x.size)+')').join('\n')+(its.length>12?'\n…':'');
if(!confirm(tr('¿Borrar de la consola? No se puede deshacer.')+'\n\n'+list+'\n\n'+tr('Liberarás unos {0}.').replace('{0}',human(sz)))) return;
const r=await api('/api/fm/delete',{paths}); if(!r.ok){toast(r.error);return}
$('spProg').hidden=false; $('spCancel').hidden=true; $('spPhase').textContent=tr('Borrando…'); $('spBar').style.width='30%';
for(;;){await new Promise(z=>setTimeout(z,800)); let o; try{o=(await api('/api/fm/op',{})).op}catch(e){continue}
if(o.busy){$('spPhase').textContent=tr('Borrando…')+' '+(o.current||''); $('spBar').style.width=(o.items_total?Math.max(5,o.items_done*100/o.items_total):30)+'%'; continue}
$('spCancel').hidden=false;
if(o.error) toast(o.error); else toast(tr('Borrado. Vuelvo a medir el espacio…'));
break}
paths.forEach(p=>sp.sel.delete(p)); spScan();
}
$('spScan').onclick=spScan;
$('spCancel').onclick=()=>api('/api/space/cancel',{});
$('spDel').onclick=()=>spDelete([...sp.sel]);
$('spRoot').onchange=spScan;
// ===================== explorador de la consola =====================
const fm={path:'/data/homebrew',entries:[],back:[],fwd:[],sel:new Set(),sizes:{},sort:'name',asc:true,clip:null,loaded:false,busy:false};
const fm={path:'/data/homebrew',entries:[],back:[],fwd:[],sel:new Set(),sizes:{},sort:'name',asc:true,clip:null,loaded:false,busy:false,via:'bfpilot',drives:[],anchor:null};
const ICON_DIR='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7fb0ff" stroke-width="2" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 8.7v9.8A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5z"/></svg>';
const ICON_FILE='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b97a5" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/></svg>';
const ICON_ARC='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e0b25a" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M10 6h2M10 9h2M10 12h2M10 15h2v3h-2z"/></svg>';
const ICON_DRIVE='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M5 13l2.5-8h9L19 13"/><circle cx="17" cy="16.5" r="1" fill="currentColor"/></svg>';
const ICON_PIN='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 8.7v9.8A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5z"/></svg>';
const ARCH_RX=/\.(part0*1\.rar|rar|7z|7z\.0*1|zip)$/i;
function joinPath(a,b){return (a.replace(/\/+$/,'')+'/'+b).replace(/\/+/g,'/')}
function parentOf(p){const x=p.replace(/\/+$/,'').split('/');x.pop();return x.join('/')||'/'}
function baseName(p){return p.replace(/\/+$/,'').split('/').pop()}
function fmtDate(t){if(!t)return '';const d=new Date(t*1000);return d.toLocaleDateString()+' '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}
function fmtMode(m){return m==null?'':(m&0o777).toString(8)}
function lsGet(k,d){try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}
function lsSet(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}}
function entryOf(p){return fm.entries.find(x=>joinPath(fm.path,x.name)===p)}
function rememberRecent(key,p){const r=lsGet(key,[]).filter(x=>x!==p); r.unshift(p); lsSet(key,r.slice(0,6))}
async function fmGo(path,noHist){
path=(path||'/').trim()||'/';
const hint=$('fmHint'); hint.textContent=tr('Cargando…');
let r; try{r=await api('/api/fm/list',{path})}catch(e){hint.textContent=e.message;return}
if(!r.ok){hint.textContent=r.error;return}
if(!noHist && fm.path!==r.path){fm.back.push(fm.path);fm.fwd=[]}
fm.path=r.path; fm.entries=r.entries; fm.sel.clear(); fm.loaded=true;
$('fmPath').value=fm.path;
if(r.total){const used=r.total-r.free; $('fmStore').innerHTML=`<b>${human(r.free)}</b> ${esc(tr('libres de'))} ${human(r.total)}<div class="bar"><i style="width:${Math.min(100,used*100/r.total).toFixed(1)}%"></i></div>`}
else $('fmStore').textContent='';
hint.textContent=r.via==='ftp'?tr('BFpilot no responde: solo lectura por FTP. Cárgalo para copiar, mover, borrar o calcular tamaños.'):'';
if(fm.path!==r.path){fm.sel.clear(); fm.anchor=null}
fm.path=r.path; fm.entries=r.entries; fm.loaded=true; fm.via=r.via;
for(const p of [...fm.sel]) if(!entryOf(p)) fm.sel.delete(p);
$('fmPath').value=fm.path; rememberRecent('ps5d_fm_recent',fm.path);
fmPaintStore(r); fmCrumbs(); fmSide();
hint.textContent=r.via==='ftp'?tr('BFpilot no está cargado: el explorador va por FTP. Copiar pasa por el PC (más lento), y para descomprimir o ver el espacio libre hace falta BFpilot.'):'';
fmRender();
}
function fmRender(){
const q=$('fmSearch').value.trim().toLowerCase();
function fmPaintStore(r){
if(r.total){const used=r.total-r.free; $('fmStore').innerHTML=`<b>${human(r.free)}</b> ${esc(tr('libres de'))} ${human(r.total)}<div class="bar"><i style="width:${Math.min(100,used*100/r.total).toFixed(1)}%"></i></div>`}
else $('fmStore').textContent='';
}
function crumbsHtml(path){
const parts=path.split('/').filter(Boolean); let acc='';
return `<button class="crumb" data-p="/">/</button>`+parts.map((x,i)=>{acc+='/'+x; return `${i?'<span class="csep">›</span>':''}<button class="crumb${i===parts.length-1?' cur':''}" data-p="${esc(acc)}" data-noi18n>${esc(x)}</button>`}).join('');
}
function fmCrumbs(){ $('fmCrumbs').innerHTML=crumbsHtml(fm.path) }
$('fmCrumbs').addEventListener('click',e=>{const b=e.target.closest('.crumb'); if(b) return fmGo(b.dataset.p);
$('fmCrumbs').hidden=true; const i=$('fmPath'); i.hidden=false; i.value=fm.path; i.focus(); i.select()});
$('fmPath').addEventListener('keydown',e=>{if(e.key==='Enter'){$('fmPath').hidden=true;$('fmCrumbs').hidden=false;fmGo($('fmPath').value)} if(e.key==='Escape'){$('fmPath').hidden=true;$('fmCrumbs').hidden=false}});
$('fmPath').addEventListener('blur',()=>{$('fmPath').hidden=true;$('fmCrumbs').hidden=false});
const FM_PLACES=[['/data/homebrew','Aplicaciones (homebrew)'],['/data','Datos (/data)'],['/data/pkg','Paquetes (/data/pkg)'],['/data/ps5directo','Restos de PS5 Directo']];
async function fmLoadDrives(){try{const r=await api('/api/fm/drives',{}); fm.drives=r.drives||[]; fmSide()}catch(e){}}
function fmSide(){
const cur=fm.path, on=p=>cur===p||cur.startsWith(p+'/');
const drives=fm.drives.length?fm.drives:[{path:'/data',name:'Almacenamiento interno'}];
const best=drives.filter(d=>on(d.path)).sort((a,b)=>b.path.length-a.path.length)[0];
let h=`<div class="side-h">${esc(tr('Unidades'))}</div>`+drives.map(d=>`<button class="side-i${best&&best.path===d.path?' on':''}" data-p="${esc(d.path)}">${ICON_DRIVE}<span><span>${esc(tr(d.name))}</span>${d.total?`<small>${esc(human(d.free))} ${esc(tr('libres'))}</small><i class="mini"><b style="width:${Math.min(100,(d.total-d.free)*100/d.total).toFixed(0)}%"></b></i>`:''}</span></button>`).join('');
h+=`<div class="side-h">${esc(tr('Accesos'))}</div>`+FM_PLACES.map(([p,n])=>`<button class="side-i${cur===p?' on':''}" data-p="${esc(p)}">${ICON_PIN}<span>${esc(tr(n))}</span></button>`).join('');
const rec=lsGet('ps5d_fm_recent',[]).filter(p=>p!==cur&&!FM_PLACES.some(x=>x[0]===p)).slice(0,5);
if(rec.length) h+=`<div class="side-h">${esc(tr('Recientes'))}</div>`+rec.map(p=>`<button class="side-i small-i" data-p="${esc(p)}" title="${esc(p)}" data-noi18n>${ICON_PIN}<span>${esc(baseName(p)||'/')}</span></button>`).join('');
$('fmSide').innerHTML=h;
}
$('fmSide').addEventListener('click',e=>{const b=e.target.closest('[data-p]'); if(b) fmGo(b.dataset.p)});
function fmVisible(){const q=$('fmSearch').value.trim().toLowerCase();
let list=fm.entries.filter(e=>!q||e.name.toLowerCase().includes(q));
const k=fm.sort, dir=fm.asc?1:-1;
list.sort((a,b)=>{ if(a.dir!==b.dir) return a.dir?-1:1;
let va=k==='size'?(a.dir?(fm.sizes[joinPath(fm.path,a.name)]??-1):a.size):k==='mtime'?a.mtime:a.name.toLowerCase();
let vb=k==='size'?(b.dir?(fm.sizes[joinPath(fm.path,b.name)]??-1):b.size):k==='mtime'?b.mtime:b.name.toLowerCase();
return (va>vb?1:va<vb?-1:0)*dir});
const body=$('fmBody');
return list}
function fmRender(){
const q=$('fmSearch').value.trim(), list=fmVisible(), body=$('fmBody');
document.querySelectorAll('.fmtable th[data-sort]').forEach(th=>th.classList.toggle('sorted',th.dataset.sort===fm.sort));
if(!list.length){body.innerHTML=`<tr><td colspan="6" class="hint" style="padding:18px;text-align:center">${esc(tr(q?'Nada coincide con la búsqueda.':'Carpeta vacía.'))}</td></tr>`}
else body.innerHTML=list.map(e=>{
const full=joinPath(fm.path,e.name), sz=e.dir?fm.sizes[full]:e.size;
return `<tr data-p="${esc(full)}" data-dir="${e.dir?1:0}" class="${fm.sel.has(full)?'sel':''}">
const full=joinPath(fm.path,e.name), sz=e.dir?fm.sizes[full]:e.size, cut=fm.clip&&fm.clip.op==='move'&&fm.clip.paths.includes(full);
const icon=e.dir?ICON_DIR:(ARCH_RX.test(e.name)?ICON_ARC:ICON_FILE);
return `<tr data-p="${esc(full)}" data-dir="${e.dir?1:0}" class="${fm.sel.has(full)?'sel':''}${cut?' cut':''}">
<td><input type="checkbox" ${fm.sel.has(full)?'checked':''}></td>
<td><div class="fname ${e.dir?'dir':''}" data-noi18n>${e.dir?ICON_DIR:ICON_FILE}<span>${esc(e.name)}</span></div></td>
<td><div class="fname ${e.dir?'dir':''}" data-noi18n>${icon}<span>${esc(e.name)}</span></div></td>
<td class="num">${sz!=null?human(sz):(e.dir?`<button class="linkbtn" data-a="du">${esc(tr('calcular'))}</button>`:'')}</td>
<td class="hide-s mono">${fmtDate(e.mtime)}</td>
<td class="hide-s mono">${fmtMode(e.mode)}</td>
<td><div class="rowact"><button class="small" data-a="ren">${esc(tr('Renombrar'))}</button><button class="small" data-a="dl">${esc(tr('Descargar'))}</button><button class="small danger" data-a="del">${esc(tr('Borrar'))}</button></div></td></tr>`}).join('');
<td><button class="small dots" data-a="menu" title="${esc(tr('Más acciones'))}">⋯</button></td></tr>`}).join('');
$('fmAll').checked=list.length>0&&list.every(e=>fm.sel.has(joinPath(fm.path,e.name)));
fmButtons();
}
function fmSelSize(){let t=0,unk=false; for(const p of fm.sel){const e=entryOf(p); if(!e) continue; const s=e.dir?fm.sizes[p]:e.size; if(s==null) unk=true; else t+=s} return [t,unk]}
function fmButtons(){
const n=fm.sel.size;
['fmCopy','fmCut','fmDownload','fmSize','fmPerms','fmDelete'].forEach(id=>$(id).disabled=n===0||fm.busy);
['fmCopy','fmCut','fmMoveTo','fmDownload','fmMore','fmDelete'].forEach(id=>$(id).disabled=n===0||fm.busy);
$('fmRename').disabled=n!==1||fm.busy;
const [t,unk]=fmSelSize();
$('fmSelInfo').textContent=n?tr('{0} seleccionados').replace('{0}',n)+(t?' · '+human(t)+(unk?'+':''):''):'';
const c=$('fmClip');
if(fm.clip){c.hidden=false;c.innerHTML=`<span>${esc(tr(fm.clip.op==='copy'?'Para copiar:':'Para mover:'))} <b data-noi18n>${fm.clip.paths.length===1?esc(fm.clip.paths[0].split('/').pop()):fm.clip.paths.length+' '+esc(tr('elementos'))}</b></span><button class="small primary" id="fmPaste">${esc(tr('Pegar aquí'))}</button><button class="small" id="fmClipX">${esc(tr('Cancelar'))}</button>`;
$('fmPaste').onclick=fmPaste; $('fmClipX').onclick=()=>{fm.clip=null;fmButtons()}}
if(fm.clip){c.hidden=false;c.innerHTML=`<span>${esc(tr(fm.clip.op==='copy'?'Para copiar:':'Para mover:'))} <b data-noi18n>${fm.clip.paths.length===1?esc(baseName(fm.clip.paths[0])):fm.clip.paths.length+' '+esc(tr('elementos'))}</b> <span class="hint" data-noi18n>${esc(tr('desde'))} ${esc(parentOf(fm.clip.paths[0]))}</span></span><button class="small primary" id="fmPaste" ${fm.busy?'disabled':''}>${esc(tr('Pegar aquí'))}</button><button class="small" id="fmClipX">${esc(tr('Cancelar'))}</button>`;
$('fmPaste').onclick=()=>fmPaste(); $('fmClipX').onclick=()=>{fm.clip=null;fmRender()}}
else c.hidden=true;
}
function fmSelect(p,ev){
const list=fmVisible().map(e=>joinPath(fm.path,e.name));
if(ev&&ev.shiftKey&&fm.anchor&&list.includes(fm.anchor)){const a=list.indexOf(fm.anchor),b=list.indexOf(p); if(!(ev.ctrlKey||ev.metaKey)) fm.sel.clear(); list.slice(Math.min(a,b),Math.max(a,b)+1).forEach(x=>fm.sel.add(x))}
else if(ev&&(ev.ctrlKey||ev.metaKey)){fm.sel.has(p)?fm.sel.delete(p):fm.sel.add(p); fm.anchor=p}
else {fm.sel.clear(); fm.sel.add(p); fm.anchor=p}
fmPaintSel();
}
// marca la seleccion sin volver a dibujar la tabla (si no, el doble clic no llega a la fila)
function fmPaintSel(){
document.querySelectorAll('#fmBody tr[data-p]').forEach(r=>{const on=fm.sel.has(r.dataset.p); r.classList.toggle('sel',on); const c=r.querySelector('input[type=checkbox]'); if(c) c.checked=on});
const vis=fmVisible(); $('fmAll').checked=vis.length>0&&vis.every(e=>fm.sel.has(joinPath(fm.path,e.name)));
fmButtons();
}
$('fmBody').addEventListener('click',async ev=>{
const tr_=ev.target.closest('tr[data-p]'); if(!tr_) return;
const p=tr_.dataset.p, isDir=tr_.dataset.dir==='1';
const a=ev.target.closest('[data-a]');
if(a){ev.stopPropagation();
if(a.dataset.a==='du') return fmDu([p]);
if(a.dataset.a==='ren') return fmRename(p);
if(a.dataset.a==='dl') return fmOpStart('/api/fm/download',{paths:[p]});
if(a.dataset.a==='del') return fmDelete([p]);
if(a.dataset.a==='menu'){ if(!fm.sel.has(p)){fm.sel.clear();fm.sel.add(p);fm.anchor=p;fmPaintSel()} const r=a.getBoundingClientRect(); return fmOpenMenu(r.right,r.bottom,'sel')}
}
if(ev.target.type==='checkbox'){ ev.target.checked?fm.sel.add(p):fm.sel.delete(p); tr_.classList.toggle('sel',ev.target.checked); fmButtons(); return }
if(ev.target.closest('.fname') && isDir) return fmGo(p);
if(fm.sel.has(p)) fm.sel.delete(p); else fm.sel.add(p);
fmRender();
if(ev.target.type==='checkbox'){ ev.target.checked?fm.sel.add(p):fm.sel.delete(p); fm.anchor=p; tr_.classList.toggle('sel',ev.target.checked); fmButtons(); return }
if(ev.target.closest('.fname') && isDir && !ev.ctrlKey && !ev.shiftKey) return fmGo(p);
fmSelect(p,ev);
});
$('fmBody').addEventListener('dblclick',ev=>{const tr_=ev.target.closest('tr[data-p]'); if(tr_&&tr_.dataset.dir==='1') fmGo(tr_.dataset.p)});
$('fmAll').onchange=()=>{const q=$('fmSearch').value.trim().toLowerCase();
fm.entries.filter(e=>!q||e.name.toLowerCase().includes(q)).forEach(e=>{const p=joinPath(fm.path,e.name); $('fmAll').checked?fm.sel.add(p):fm.sel.delete(p)}); fmRender()};
$('fmBody').addEventListener('dblclick',ev=>{const tr_=ev.target.closest('tr[data-p]'); if(!tr_) return; if(tr_.dataset.dir==='1') fmGo(tr_.dataset.p); else if(ARCH_RX.test(tr_.dataset.p)) fmExtract(tr_.dataset.p)});
$('fmBody').addEventListener('contextmenu',ev=>{const tr_=ev.target.closest('tr[data-p]'); ev.preventDefault();
if(!tr_) return fmOpenMenu(ev.clientX,ev.clientY,'bg');
const p=tr_.dataset.p; if(!fm.sel.has(p)){fm.sel.clear();fm.sel.add(p);fm.anchor=p;fmPaintSel()}
fmOpenMenu(ev.clientX,ev.clientY,'sel')});
$('fmTableWrap').addEventListener('contextmenu',ev=>{if(ev.target.closest('tr[data-p]')||ev.target.closest('thead')) return; ev.preventDefault(); fm.sel.clear(); fmRender(); fmOpenMenu(ev.clientX,ev.clientY,'bg')});
$('fmAll').onchange=()=>{fmVisible().forEach(e=>{const p=joinPath(fm.path,e.name); $('fmAll').checked?fm.sel.add(p):fm.sel.delete(p)}); fmRender()};
document.querySelectorAll('.fmtable th[data-sort]').forEach(th=>th.onclick=()=>{const k=th.dataset.sort; fm.asc=fm.sort===k?!fm.asc:true; fm.sort=k; fmRender()});
$('fmSearch').addEventListener('input',fmRender);
$('fmPath').addEventListener('keydown',e=>{if(e.key==='Enter') fmGo($('fmPath').value)});
$('fmRefresh').onclick=()=>fmGo(fm.path,true);
$('fmUp').onclick=()=>fmGo(parentOf(fm.path));
$('fmBack').onclick=()=>{if(!fm.back.length)return; fm.fwd.push(fm.path); fmGo(fm.back.pop(),true)};
$('fmFwd').onclick=()=>{if(!fm.fwd.length)return; fm.back.push(fm.path); fmGo(fm.fwd.pop(),true)};
$('fmPlaces').onchange=()=>{const v=$('fmPlaces').value; $('fmPlaces').value=''; if(v) fmGo(v)};
async function fmPlaces(){
const base=['/data/homebrew','/data','/data/pkg','/data/ps5directo','/mnt'];
try{const r=await api('/api/places',{}); (r.dests||[]).forEach(d=>{if(!base.includes(d))base.push(d)})}catch(e){}
$('fmPlaces').innerHTML=`<option value="">${esc(tr('Ir a…'))}</option>`+base.map(d=>`<option value="${esc(d)}">${esc(d)}</option>`).join('');
}
fmPlaces();
function selList(){return [...fm.sel]}
$('fmCopy').onclick=()=>{fm.clip={op:'copy',paths:selList()};fm.sel.clear();fmRender();toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí"'))};
$('fmCut').onclick=()=>{fm.clip={op:'move',paths:selList()};fm.sel.clear();fmRender();toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí"'))};
$('fmRename').onclick=()=>fmRename(selList()[0]);
$('fmDownload').onclick=()=>fmOpStart('/api/fm/download',{paths:selList()});
$('fmSize').onclick=()=>fmDu(selList().filter(p=>{const e=fm.entries.find(x=>joinPath(fm.path,x.name)===p);return e&&e.dir}));
$('fmPerms').onclick=()=>fmOpStart('/api/fm/chmod',{paths:selList()});
$('fmDelete').onclick=()=>fmDelete(selList());
// ---- menu contextual ----
function fmOpenMenu(x,y,kind){
const m=$('fmMenu'), sel=selList(), one=sel.length===1?entryOf(sel[0]):null, items=[];
const it=(a,label,key,cls)=>items.push(`<button data-m="${a}" class="${cls||''}">${esc(tr(label))}${key?`<kbd>${esc(tr(key))}</kbd>`:''}</button>`);
const sep=()=>items.push('<hr>');
if(kind==='bg'){
if(fm.clip) it('paste','Pegar aquí','Ctrl+V'), sep();
it('mkdir','Nueva carpeta'); it('upf','Subir archivos aquí'); it('upd','Subir carpeta aquí'); sep();
it('refresh','Actualizar la carpeta','F5'); it('copypath','Copiar ruta de esta carpeta');
} else {
if(one&&one.dir) it('open','Abrir','Enter');
if(one&&!one.dir&&ARCH_RX.test(one.name)) it('extract','Extraer…');
it('download','Descargar al PC'); sep();
it('copy','Copiar','Ctrl+C'); it('cut','Cortar','Ctrl+X');
if(fm.clip&&one&&one.dir) it('pasteinto','Pegar dentro');
it('copyto','Copiar a…'); it('moveto','Mover a…'); sep();
if(one) it('rename','Renombrar','F2');
if(sel.some(p=>{const e=entryOf(p);return e&&e.dir})) it('du','Calcular tamaño');
it('chmod','Permisos 777'); it('copypath',sel.length>1?'Copiar rutas':'Copiar ruta'); sep();
it('delete','Borrar','Supr','danger');
}
m.innerHTML=items.join(''); m.hidden=false;
const w=m.offsetWidth,h=m.offsetHeight;
m.style.left=Math.max(6,Math.min(x,innerWidth-w-6))+'px'; m.style.top=Math.max(6,Math.min(y,innerHeight-h-6))+'px';
m.querySelector('button')?.focus();
}
function fmMenuClose(){$('fmMenu').hidden=true}
document.addEventListener('mousedown',e=>{if(!e.target.closest('#fmMenu')) fmMenuClose()});
addEventListener('blur',fmMenuClose); addEventListener('resize',fmMenuClose); document.addEventListener('scroll',fmMenuClose,true);
$('fmMenu').addEventListener('keydown',e=>{const bs=[...$('fmMenu').querySelectorAll('button')], i=bs.indexOf(document.activeElement);
if(e.key==='ArrowDown'){e.preventDefault();bs[(i+1)%bs.length].focus()} else if(e.key==='ArrowUp'){e.preventDefault();bs[(i-1+bs.length)%bs.length].focus()} else if(e.key==='Escape') fmMenuClose()});
$('fmMenu').addEventListener('click',e=>{const b=e.target.closest('[data-m]'); if(!b) return; fmMenuClose(); fmAct(b.dataset.m)});
function fmAct(a){
const sel=selList();
switch(a){
case 'open': return fmGo(sel[0]);
case 'extract': return fmExtract(sel[0]);
case 'download': return fmOpStart('/api/fm/download',{paths:sel});
case 'copy': case 'cut': fm.clip={op:a==='copy'?'copy':'move',paths:sel}; fmRender(); return toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí" (o Ctrl+V)'));
case 'paste': return fmPaste();
case 'pasteinto': return fmPaste(sel[0]);
case 'copyto': return fmPick('copy',sel);
case 'moveto': return fmPick('move',sel);
case 'rename': return fmRename(sel[0]);
case 'du': return fmDu(sel.filter(p=>{const e=entryOf(p);return e&&e.dir}));
case 'chmod': return fmOpStart('/api/fm/chmod',{paths:sel});
case 'copypath': { const t=(sel.length?sel:[fm.path]).join('\n'); navigator.clipboard?.writeText(t).then(()=>toast(tr('Ruta copiada')),()=>toast(t)); return }
case 'delete': return fmDelete(sel);
case 'mkdir': return $('fmMkdir').click();
case 'upf': return fmUpload('files');
case 'upd': return fmUpload('folder');
case 'refresh': return fmGo(fm.path,true);
}
}
$('fmCopy').onclick=()=>fmAct('copy');
$('fmCut').onclick=()=>fmAct('cut');
$('fmMoveTo').onclick=()=>fmAct('moveto');
$('fmRename').onclick=()=>fmAct('rename');
$('fmDownload').onclick=()=>fmAct('download');
$('fmDelete').onclick=()=>fmAct('delete');
$('fmMore').onclick=()=>{const r=$('fmMore').getBoundingClientRect(); fmOpenMenu(r.left,r.bottom+4,'sel')};
// ---- atajos de teclado (solo en la pestaña de archivos y fuera de las casillas de texto) ----
document.addEventListener('keydown',e=>{
if(curTab!=='Files'||tourEls||document.querySelector('dialog[open]')) return;
if(!$('fmMenu').hidden) return;
const t=e.target, typing=t&&(t.tagName==='INPUT'&&t.type!=='checkbox'||t.tagName==='TEXTAREA'||t.tagName==='SELECT');
if(typing) return;
const k=e.key, ctrl=e.ctrlKey||e.metaKey, sel=selList();
if(k==='F5'){e.preventDefault(); return fmGo(fm.path,true)}
if(k==='Backspace'||(e.altKey&&k==='ArrowUp')){e.preventDefault(); return fmGo(parentOf(fm.path))}
if(e.altKey&&k==='ArrowLeft'){e.preventDefault(); return $('fmBack').click()}
if(e.altKey&&k==='ArrowRight'){e.preventDefault(); return $('fmFwd').click()}
if(ctrl&&k.toLowerCase()==='a'){e.preventDefault(); fmVisible().forEach(x=>fm.sel.add(joinPath(fm.path,x.name))); return fmPaintSel()}
if(ctrl&&k.toLowerCase()==='v'){e.preventDefault(); return fmPaste()}
if(k==='Escape'){fm.sel.clear(); return fmPaintSel()}
if(!sel.length||fm.busy) return;
if(k==='Delete'){e.preventDefault(); return fmAct('delete')}
if(k==='F2'&&sel.length===1){e.preventDefault(); return fmAct('rename')}
if(ctrl&&k.toLowerCase()==='c'&&!getSelection().toString()){e.preventDefault(); return fmAct('copy')}
if(ctrl&&k.toLowerCase()==='x'){e.preventDefault(); return fmAct('cut')}
if(k==='Enter'&&sel.length===1){const en=entryOf(sel[0]); if(en&&en.dir){e.preventDefault(); return fmGo(sel[0])}}
});
// ---- acciones ----
$('fmMkdir').onclick=async()=>{const n=prompt(tr('Nombre de la carpeta nueva:'),''); if(!n) return;
if(/[\/\\]/.test(n)||n==='.'||n==='..') return toast(tr('El nombre no puede llevar / ni \\'));
const r=await api('/api/fm/mkdir',{path:joinPath(fm.path,n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); fmGo(fm.path,true)};
async function fmRename(p){const old=p.split('/').pop(); const n=prompt(tr('Nuevo nombre:'),old); if(!n||n===old) return;
const r=await api('/api/fm/rename',{src:p,dst:joinPath(parentOf(p),n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); fmGo(fm.path,true)}
async function fmRename(p){const old=baseName(p); const n=prompt(tr('Nuevo nombre:'),old); if(!n||n===old) return;
if(/[\/\\]/.test(n)||n==='.'||n==='..') return toast(tr('El nombre no puede llevar / ni \\'));
const r=await api('/api/fm/rename',{src:p,dst:joinPath(parentOf(p),n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error);
fm.sel.clear(); fm.sel.add(joinPath(parentOf(p),n)); fmGo(fm.path,true)}
async function fmDu(paths){for(const p of paths){fm.sizes[p]=undefined; try{const r=await api('/api/fm/du',{path:p}); if(r.ok) fm.sizes[p]=r.size; else toast(r.error)}catch(e){toast(e.message)} fmRender()}}
function fmDelete(paths){if(!paths.length) return;
const names=paths.map(p=>p.split('/').pop()); const list=names.slice(0,6).join('\n')+(names.length>6?'\n…':'');
const names=paths.map(baseName); const list=names.slice(0,6).join('\n')+(names.length>6?'\n…':'');
if(!confirm(tr('¿Borrar {0} elemento(s) de la consola? No se puede deshacer.').replace('{0}',paths.length)+'\n\n'+list)) return;
fmOpStart('/api/fm/delete',{paths})}
async function fmPaste(){if(!fm.clip) return; const c=fm.clip;
const r=await fmOpStart('/api/fm/paste',{op:c.op,srcs:c.paths,dst:fm.path}); if(r) {fm.clip=null; fmButtons()}}
async function fmUpload(kind){const ps=await pick(kind); if(!ps.length) return;
fmOpStart('/api/fm/delete',{paths}).then(ok=>{if(ok){paths.forEach(p=>fm.sel.delete(p))}})}
// pegar con aviso de conflictos
function fmAskConflict(conf,op){
return new Promise(res=>{
const d=$('dlgConflict');
$('cfTitle').textContent=conf.length===1?tr('"{0}" ya está aquí').replace('{0}',conf[0].name):tr('Ya hay {0} elementos con el mismo nombre').replace('{0}',conf.length);
$('cfBody').textContent=tr(op==='copy'?'¿Qué hago con los que ya existen en la carpeta de destino?':'¿Qué hago con los que ya existen en la carpeta de destino?');
$('cfList').innerHTML=conf.slice(0,8).map(c=>`<li>${c.dir?ICON_DIR:ICON_FILE} ${esc(c.name)}</li>`).join('')+(conf.length>8?'<li>…</li>':'');
const allDirs=conf.every(c=>c.dir), allFiles=conf.every(c=>!c.dir);
$('cfReplace').textContent=tr(allDirs?'Combinar':allFiles?'Reemplazar':'Reemplazar / combinar');
$('cfReplace').hidden=conf.some(c=>c.same);
d.querySelectorAll('.cf-opts button').forEach(b=>b.onclick=()=>{d.close(); res(b.dataset.m)});
d.onclose=()=>res(''); d.showModal();
});
}
async function fmDoPaste(op,srcs,dst){
let conf=[]; try{conf=(await api('/api/fm/conflicts',{srcs,dst})).conflicts||[]}catch(e){}
if(op==='move') conf=conf.filter(c=>!c.same);
let mode='replace';
if(conf.length){mode=await fmAskConflict(conf,op); if(!mode) return false}
return fmOpStart('/api/fm/paste',{op,srcs,dst,mode});
}
async function fmPaste(into){if(!fm.clip||fm.busy) return; const c=fm.clip, dst=into||fm.path;
if(c.paths.some(p=>dst===p||dst.startsWith(p+'/'))) return toast(tr('No se puede pegar una carpeta dentro de sí misma'));
const ok=await fmDoPaste(c.op,c.paths,dst); if(ok){ if(c.op==='move') fm.clip=null; rememberRecent('ps5d_fm_dest',dst); fmButtons()}}
async function fmUpload(kind){const ps=await pick(kind); if(!ps.length) return; fmUploadPaths(ps)}
async function fmUploadPaths(ps){
try{await api('/api/add',{lines:ps.join('\n'),dest:fm.path,raw:true}); toast(tr('Añadido a la cola: se sube tal cual a {0}').replace('{0}',fm.path)); refresh()}catch(e){toast(e.message)}}
$('fmUpFiles').onclick=()=>fmUpload('files');
$('fmUpFolder').onclick=()=>fmUpload('folder');
// operaciones largas (copiar, mover, borrar, bajar, permisos) con progreso
const OPNAME={copy:'Copiando',move:'Moviendo',delete:'Borrando',download:'Descargando al PC',chmod:'Poniendo permisos'};
// ---- Mover a… / Copiar a… ----
const pk={op:'move',srcs:[],path:'/data'};
async function fmPick(op,srcs){
pk.op=op; pk.srcs=srcs;
$('pickTitle').textContent=(op==='move'?tr('Mover {0} a…'):tr('Copiar {0} a…')).replace('{0}',srcs.length===1?'«'+baseName(srcs[0])+'»':tr('{0} elementos').replace('{0}',srcs.length));
$('pickGo').textContent=tr(op==='move'?'Mover aquí':'Copiar aquí');
const rec=lsGet('ps5d_fm_dest',[]);
$('pickSide').innerHTML=`<div class="side-h">${esc(tr('Unidades'))}</div>`+(fm.drives.length?fm.drives:[{path:'/data',name:'Almacenamiento interno'}]).map(d=>`<button class="side-i" data-p="${esc(d.path)}">${ICON_DRIVE}<span>${esc(tr(d.name))}</span></button>`).join('')
+`<div class="side-h">${esc(tr('Accesos'))}</div>`+FM_PLACES.map(([p,n])=>`<button class="side-i" data-p="${esc(p)}">${ICON_PIN}<span>${esc(tr(n))}</span></button>`).join('')
+(rec.length?`<div class="side-h">${esc(tr('Destinos recientes'))}</div>`+rec.map(p=>`<button class="side-i small-i" data-p="${esc(p)}" title="${esc(p)}" data-noi18n>${ICON_PIN}<span>${esc(p)}</span></button>`).join(''):'');
$('dlgPick').showModal(); pickGo(rec[0]||parentOf(srcs[0]));
}
async function pickGo(path){
$('pickList').innerHTML=`<p class="hint">${esc(tr('Cargando…'))}</p>`;
let r; try{r=await api('/api/fm/dirs',{path})}catch(e){$('pickList').innerHTML=`<p class="err">${esc(e.message)}</p>`;return}
pk.path=r.path; $('pickCrumbs').innerHTML=crumbsHtml(r.path);
$('pickList').innerHTML=r.dirs.length?r.dirs.map(d=>{const full=joinPath(r.path,d), bad=pk.srcs.includes(full);
return `<button class="pick-i" data-p="${esc(full)}" ${bad?'disabled':''} data-noi18n>${ICON_DIR}<span>${esc(d)}</span></button>`}).join(''):`<p class="hint">${esc(tr('No hay carpetas dentro.'))}</p>`;
const into=pk.srcs.some(p=>r.path===p||r.path.startsWith(p+'/')), here=pk.op==='move'&&pk.srcs.every(p=>parentOf(p)===r.path);
$('pickGo').disabled=into||here;
$('pickNote').textContent=into?tr('Una carpeta no se puede meter dentro de sí misma.'):here?tr('Ya está en esta carpeta.'):
(pk.op==='move'&&fm.via==='ftp'&&!sameDrive(pk.srcs[0],r.path)?tr('Otra unidad sin BFpilot: se copia pasando por el PC y luego se borra el original.'):'');
}
function driveOf(p){const m=p.match(/^\/mnt\/(usb|ext)\d+/); return m?m[0]:'/data'}
function sameDrive(a,b){return driveOf(a)===driveOf(b)}
$('pickCrumbs').addEventListener('click',e=>{const b=e.target.closest('.crumb'); if(b) pickGo(b.dataset.p)});
$('pickList').addEventListener('click',e=>{const b=e.target.closest('[data-p]'); if(b&&!b.disabled) pickGo(b.dataset.p)});
$('pickSide').addEventListener('click',e=>{const b=e.target.closest('[data-p]'); if(b) pickGo(b.dataset.p)});
$('pickUp').onclick=()=>pickGo(parentOf(pk.path));
$('pickX').onclick=()=>$('dlgPick').close();
$('pickNew').onclick=async()=>{const n=prompt(tr('Nombre de la carpeta nueva:'),''); if(!n) return; if(/[\/\\]/.test(n)) return toast(tr('El nombre no puede llevar / ni \\'));
const r=await api('/api/fm/mkdir',{path:joinPath(pk.path,n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); pickGo(joinPath(pk.path,n))};
$('pickGo').onclick=async()=>{const dst=pk.path; $('dlgPick').close();
const ok=await fmDoPaste(pk.op,pk.srcs,dst); if(ok){rememberRecent('ps5d_fm_dest',dst); if(pk.op==='move') fm.sel.clear()}};
// ---- Extraer… ----
let exSrc='';
function fmExtract(p){
exSrc=p; const name=baseName(p);
$('exTitle').textContent=tr('Extraer «{0}»').replace('{0}',name);
$('exFolder').value=name.replace(/\.part0*1\.rar$/i,'').replace(/\.7z\.0*1$/i,'').replace(/\.(rar|7z|zip)$/i,'');
$('exPw').value=''; const taken=fm.entries.some(e=>e.name===$('exFolder').value);
$('exNote').textContent=fm.via==='ftp'?tr('BFpilot no está cargado: cárgalo en la PS5 para poder extraer.'):taken?tr('Ya hay una carpeta con ese nombre: se usará uno nuevo terminado en (2).'):'';
$('exGo').disabled=fm.via==='ftp';
$('dlgExtract').showModal(); $('exFolder').focus();
}
$('exCancel').onclick=()=>$('dlgExtract').close();
$('exGo').onclick=async()=>{const folder=$('exFolder').value.trim(); if(!folder||/[\/\\]/.test(folder)) return toast(tr('Pon un solo nombre de carpeta, sin /'));
$('dlgExtract').close(); fmOpStart('/api/fm/extract',{src:exSrc,folder,password:$('exPw').value})};
// ---- arrastrar archivos del PC a la carpeta abierta ----
$('tabFiles').addEventListener('dragover',e=>{if(curTab!=='Files'||!e.dataTransfer||![...e.dataTransfer.types].includes('Files')) return; e.preventDefault(); e.stopPropagation(); $('fmTableWrap').classList.add('over'); $('fmTableWrap').dataset.drop=tr('Suelta para subir a {0}').replace('{0}',fm.path)});
$('tabFiles').addEventListener('dragleave',e=>{if(!$('tabFiles').contains(e.relatedTarget)) $('fmTableWrap').classList.remove('over')});
$('tabFiles').addEventListener('drop',async e=>{if(curTab!=='Files'||!e.dataTransfer||!e.dataTransfer.files.length) return; e.preventDefault(); e.stopPropagation(); $('fmTableWrap').classList.remove('over');
const files=[...e.dataTransfer.files].map(f=>({name:f.name,size:f.size,lastModified:f.lastModified}));
toast(tr('Buscando {0} archivo(s) en tu PC…').replace('{0}',files.length));
let r; try{r=await api('/api/locate',{files})}catch(err){return toast(err.message)}
const found=Object.values(r.found||{}); if(!found.length) return toast(tr('No los encuentro en tu PC: usa "Subir archivos aquí"'));
fmUploadPaths(found)});
// operaciones largas (copiar, mover, borrar, bajar, permisos, extraer) con progreso
const OPNAME={copy:'Copiando',move:'Moviendo',delete:'Borrando',download:'Descargando al PC',chmod:'Poniendo permisos',extract:'Extrayendo'};
let fmPoll=null;
async function fmOpStart(url,body){
try{const r=await api(url,body); if(!r.ok){toast(r.error);return false}}catch(e){toast(e.message);return false}
fm.busy=true; fmButtons(); fmWatch(); return true;
}
function fmWatch(){clearInterval(fmPoll); fmPoll=setInterval(fmPollOp,900); fmPollOp()}
function fmtSecs(n){n=Math.max(0,Math.round(n));const m=Math.floor(n/60);return m?m+' min '+(n%60)+' s':n+' s'}
let fmStoreT=0;
async function fmPollOp(){
let r; try{r=await api('/api/fm/op',{})}catch(e){return}
// mientras borra o copia, el espacio libre de arriba se va actualizando
if(r.op&&r.op.busy&&Date.now()-fmStoreT>4000){fmStoreT=Date.now(); api('/api/fm/stat',{path:fm.path}).then(x=>{if(x.ok)fmPaintStore(x)}).catch(()=>{})}
const o=r.op, box=$('fmOp');
if(!o.op){box.hidden=true;return}
if(!o.op){box.hidden=true;clearInterval(fmPoll);fm.busy=false;return}
box.hidden=false;
const pct=o.bytes_total>0?Math.min(100,o.bytes_done*100/o.bytes_total):(o.items_total?o.items_done*100/o.items_total:0);
if(o.busy){
box.innerHTML=`<b>${esc(tr(OPNAME[o.op]||o.op))}</b> <span class="hint" data-noi18n>${esc((o.current||'').split('/').pop())}</span>
<div class="bar"><i style="width:${pct.toFixed(1)}%"></i></div>
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}</span>
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}${o.op==='delete'&&o.bytes_done?' · '+esc(tr('liberados {0}').replace('{0}',human(o.bytes_done||0))):''} · ${fmtSecs(Date.now()/1000-(o.t0||Date.now()/1000))}</span>
<button class="small" id="fmOpCancel">${esc(tr('Cancelar'))}</button>`;
$('fmOpCancel').onclick=()=>api('/api/fm/cancel',{});
} else {
clearInterval(fmPoll); fm.busy=false; fmButtons();
box.innerHTML=o.error?`<span class="err" style="margin:0">${esc(o.error)}</span><button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`
:`<span>✔ ${esc(o.message||tr('Hecho'))}</span>${o.op==='download'?`<button class="small" id="fmOpenLocal">${esc(tr('Abrir carpeta'))}</button>`:''}<button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`;
const was=fm.busy; clearInterval(fmPoll); fm.busy=false; fmButtons();
box.innerHTML=o.error?`<span class="err" style="margin:0">${esc(tr(o.error))}</span><button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`
:`<span>✔ ${esc(tr(o.message||'Hecho'))}</span>${o.op==='download'?`<button class="small" id="fmOpenLocal">${esc(tr('Abrir carpeta'))}</button>`:''}<button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`;
$('fmOpOk').onclick=()=>{box.hidden=true};
if($('fmOpenLocal')) $('fmOpenLocal').onclick=()=>api('/api/fm/open_local',{});
if(o.op!=='download') fmGo(fm.path,true);
if(o.op!=='download'&&was) fmGo(fm.path,true);
}
}
fmWatch();
fmWatch(); fmLoadDrives(); setInterval(()=>{if(curTab==='Files')fmLoadDrives()},60000);
// ===================== campana de notificaciones =====================
let notifs=[], nFresh=new Set();
function paintBell(n){const b=$('bellN'); b.hidden=!n; b.textContent=n>99?'99+':String(n); $('btnBell').classList.toggle('has',!!n)}
function ago(t){const s=Math.max(0,Date.now()/1000-t); if(s<60) return tr('ahora'); if(s<3600) return tr('hace {0} min').replace('{0}',Math.floor(s/60)); if(s<86400) return tr('hace {0} h').replace('{0}',Math.floor(s/3600)); return new Date(t*1000).toLocaleDateString()}
async function loadNotifs(){try{const r=await api('/api/notifs',{}); notifs=r.items||[]; paintBell(r.unread||0); renderNotifs()}catch(e){}}
function renderNotifs(){
const box=$('nList');
if(!notifs.length){box.innerHTML=`<p class="hint" style="padding:18px;text-align:center">${esc(tr('Aún no hay avisos. Aquí aparecerán las transferencias terminadas, los errores, las desconexiones y las versiones nuevas.'))}</p>`;return}
box.innerHTML=notifs.map(n=>`<div class="nitem ${n.level}${nFresh.has(n.id)?' fresh':''}${n.link?' link':''}" data-id="${esc(n.id)}">
<span class="ndot"></span>
<div class="nbody"><b>${esc(tr(n.title))}</b>${n.body?`<span>${esc(tr(n.body))}</span>`:''}<small>${esc(ago(n.t))}</small></div>
<button class="nx" data-x title="${esc(tr('Descartar'))}">×</button></div>`).join('');
}
async function openNotifs(){
const p=$('nPanel'); if(!p.hidden){p.hidden=true;return}
await loadNotifs(); nFresh=new Set(notifs.filter(n=>!n.read).map(n=>n.id)); renderNotifs();
p.hidden=false;
if(nFresh.size) api('/api/notifs/read',{all:true}).then(()=>paintBell(0)).catch(()=>{});
}
$('btnBell').onclick=e=>{e.stopPropagation(); openNotifs()};
document.addEventListener('mousedown',e=>{if(!e.target.closest('#nPanel')&&!e.target.closest('#btnBell')) $('nPanel').hidden=true});
document.addEventListener('keydown',e=>{if(e.key==='Escape') $('nPanel').hidden=true});
$('nAllRead').onclick=async()=>{await api('/api/notifs/read',{all:true}).catch(()=>{}); nFresh.clear(); notifs.forEach(n=>n.read=true); paintBell(0); renderNotifs()};
$('nClear').onclick=async()=>{if(!notifs.length) return; await api('/api/notifs/clear',{}).catch(()=>{}); notifs=[]; paintBell(0); renderNotifs()};
$('nList').addEventListener('click',async e=>{
const it=e.target.closest('.nitem'); if(!it) return; const n=notifs.find(x=>x.id===it.dataset.id); if(!n) return;
if(e.target.closest('[data-x]')){await api('/api/notifs/remove',{id:n.id}).catch(()=>{}); notifs=notifs.filter(x=>x!==n); renderNotifs(); return}
const l=n.link; if(!l) return; $('nPanel').hidden=true;
if(l.action==='update') return $('btnUpdHead').click();
if(l.tab==='Files'){showTab('Files'); if(l.path) fmGo(l.path); return}
if(l.tab==='Transfers'){showTab('Transfers'); setTimeout(()=>{const c=document.querySelector(`.job[data-id="${CSS.escape(l.job||'')}"]`); if(c){c.scrollIntoView({block:'center',behavior:'smooth'});c.classList.add('flash');setTimeout(()=>c.classList.remove('flash'),1600)} else showTab('Activity')},150)}
});
setInterval(()=>{const n=$('nList'); if(!$('nPanel').hidden) renderNotifs()},30000);
// ===================== actividad =====================
async function loadActivity(){
+29 -8
View File
@@ -1,11 +1,32 @@
{
"manifest_version": 3,
"name": "PS5 Directo - Capturar enlaces",
"version": "1.0.0",
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace a PS5 Directo en vez de bajarlo al PC.",
"permissions": ["downloads", "storage", "notifications"],
"host_permissions": ["http://127.0.0.1/*"],
"background": { "service_worker": "bg.js" },
"action": { "default_popup": "popup.html", "default_title": "PS5 Directo", "default_icon": { "16": "icon16.png", "32": "icon32.png" } },
"icons": { "16": "icon16.png", "48": "icon48.png", "128": "icon128.png" }
}
"version": "1.1.0",
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace (con la sesion del navegador) a PS5 Directo en vez de bajarlo al PC.",
"permissions": [
"downloads",
"storage",
"notifications",
"cookies"
],
"host_permissions": [
"http://127.0.0.1/*",
"<all_urls>"
],
"background": {
"service_worker": "bg.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "PS5 Directo",
"default_icon": {
"16": "icon16.png",
"32": "icon32.png"
}
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}
+3 -2
View File
@@ -404,7 +404,7 @@ tr:hover .rowact{opacity:1}
<div class="toast" id="toast"></div>
<script>
const I18N_EXACT={"Novedades": "What's new", "Entendido": "Got it", "Se ha actualizado. Esto es lo nuevo:": "PS5 Directo has been updated. Here is what's new:", "Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa más y qué puedes borrar. Actividad guarda el historial.": "Transfers is the queue. In Console files you browse the PS5: see sizes, copy, move, rename, delete, download to the PC or upload files and folders. Space shows in a chart what takes up the most room and what you can delete. Activity keeps the history.", "Espacio": "Space", "Almacenamiento interno": "Internal storage", "Analizar": "Analyze", "Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.": "See what takes up space on the console, with names and sizes, to decide what to delete. It flags repeated games (for example a folder and its converted version), half-sent transfers and leftovers. Needs BFpilot.", "Todo lo que ocupa sitio": "Everything that takes up space", "Borrar seleccionados": "Delete selected", "Disco externo": "External drive", "Libre": "Free", "Abrir": "Open", "Borrando…": "Deleting…", "¿Borrar de la consola? No se puede deshacer.": "Delete from the console? This cannot be undone.", "Juegos instalados desde la PS5": "Games installed from the PS5", "Sistema y otros datos de la consola": "System and other console data", "Otros": "Other", "Borrado. Vuelvo a medir el espacio…": "Deleted. Measuring the space again…", "Envía archivos a tu consola por la red local, sin pasar por el disco del PC": "Send files to your console over your local network, without going through your PC's disk", "Guía": "Guide", "Guía rápida: para qué es cada cosa": "Quick guide: what each part does", "Extensión": "Extension", "Capturar enlaces desde el navegador sin copiar y pegar": "Capture links from your browser without copy and paste", "Actualizar": "Update", "Buscar o instalar una versión nueva": "Check for or install a new version", "Ajustes": "Settings", "Salir": "Quit", "Cierra PS5 Directo del todo": "Closes PS5 Directo completely", "Nueva transferencia": "New transfer", "Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…": "Direct links (one per line). Paste all parts together: .part1.rar, .part2.rar… or .001, .002…", "https://servidor/archivo.part1.rar": "https://server/file.part1.rar", "https://servidor/archivo.part2.rar": "https://server/file.part2.rar", "También vale la ruta de un archivo de tu PC, por ejemplo: D:\\Descargas\\archivo.rar": "A file path on your PC also works, for example: D:\\Downloads\\file.rar", "Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el": "Files on your PC: drop them on this page and I'll find them on your PC, or drag them onto the", "icono de PS5 Directo": "PS5 Directo icon", ", o clic derecho →": ", or right-click →", "Enviar a → PS5 Directo": "Send to → PS5 Directo", "Contraseña del comprimido (si la tiene)": "Archive password (if any)", "Opcional. Si la dejas vacía prueba las de Ajustes y las del nombre": "Optional. If empty, it tries the ones in Settings and the one in the file name", "Destino en la PS5": "Destination on the PS5", "Dónde descomprimir": "Where to extract", "En la PS5 (BFpilot)": "On the PS5 (BFpilot)", "En el PC (7-Zip)": "On the PC (7-Zip)", "Añadir a la cola": "Add to queue", "Si pegas enlaces de varios archivos distintos, los separa solo. «En el PC» usa 7-Zip y manda el contenido ya descomprimido y comprobado (más datos por el Wi-Fi, pero sin BFpilot).": "If you paste links to several different files, it splits them automatically. \"On the PC\" uses 7-Zip and sends the content already extracted and verified (more data over Wi-Fi, but no BFpilot needed).", "Cola": "Queue", "Quitar terminados": "Clear finished", "No hay nada en la cola. Pega enlaces arriba.": "The queue is empty. Paste links above.", "Transferencia": "Transfer", "Pausar": "Pause", "Reanudar": "Resume", "Cancelar": "Cancel", "Reintentar": "Retry", "Quitar": "Remove", "Test de velocidad": "Speed test", "Mide descarga, envío y las dos cosas a la vez": "Measures download, upload and both at once", "Bajar al PC": "Download to PC", "Descomprimir y enviar": "Extract and send", "Colocar": "Place", "Listo": "Done", "Enviar": "Send", "Descomprimir": "Extract", "Enviado": "Sent", "de": "of", "Velocidad a la PS5": "Speed to PS5", "Quedan": "Remaining", "Descompresión en la PS5": "Extraction on the PS5", "Archivos y registro": "Files and log", "Copiar registro": "Copy log", "Registro copiado. Pégalo donde quieras (Ctrl+V).": "Log copied. Paste it anywhere (Ctrl+V).", "Registro copiado.": "Log copiLine truncated
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
// --- idioma: español (original) o inglés. Traduce el texto de la página y los mensajes del programa.
const LANG=(()=>{try{const v=localStorage.getItem('ps5d_lang');if(v==='es'||v==='en')return v}catch(e){}
return (navigator.language||'es').toLowerCase().startsWith('es')?'es':'en'})();
@@ -1143,6 +1143,7 @@ async function fmOpStart(url,body){
fm.busy=true; fmButtons(); fmWatch(); return true;
}
function fmWatch(){clearInterval(fmPoll); fmPoll=setInterval(fmPollOp,900); fmPollOp()}
function fmtSecs(n){n=Math.max(0,Math.round(n));const m=Math.floor(n/60);return m?m+' min '+(n%60)+' s':n+' s'}
async function fmPollOp(){
let r; try{r=await api('/api/fm/op',{})}catch(e){return}
const o=r.op, box=$('fmOp');
@@ -1152,7 +1153,7 @@ async function fmPollOp(){
if(o.busy){
box.innerHTML=`<b>${esc(tr(OPNAME[o.op]||o.op))}</b> <span class="hint" data-noi18n>${esc((o.current||'').split('/').pop())}</span>
<div class="bar"><i style="width:${pct.toFixed(1)}%"></i></div>
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}</span>
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}${o.op==='delete'?' · '+esc(tr('liberados {0}').replace('{0}',human(o.bytes_done||0)))+' · '+fmtSecs(Date.now()/1000-(o.t0||Date.now()/1000)):''}</span>
<button class="small" id="fmOpCancel">${esc(tr('Cancelar'))}</button>`;
$('fmOpCancel').onclick=()=>api('/api/fm/cancel',{});
} else {