Version 1.16.0

This commit is contained in:
JesterDev authored and GitHub committed 2026-10-16 15:41:04 +02:00
1 parent 65911f790d
commit 4fc970e7d8
9 files changed
+1097 -28

No files matched your search

+27 -1
View File
@@ -1,4 +1,4 @@
PS5 DIRECTO 1.15
PS5 DIRECTO 1.16
================
(English: see README_EN.txt)
@@ -16,6 +16,25 @@ Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola:
Mientras una transferencia se descomprime en la PS5, la siguiente de la cola ya se
va enviando.
PESTAÑAS
- Transferencias: la cola de siempre.
- Archivos de la consola: navega por la PS5 como en un explorador. Ves el
tamaño y la fecha de cada archivo (el de las carpetas, con "calcular"),
y puedes crear carpetas, renombrar, copiar o mover (Copiar/Mover y
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.
- Actividad: historial de lo que se ha terminado o ha fallado.
CARPETAS DEL PC
Con "Elegir carpeta…" (o arrastrando la carpeta al icono) se manda una
carpeta entera tal cual. Si tiene sce_sys, va a /data/homebrew como
TITLEID-app. Si en la consola ya hay parte (por ejemplo, un envío de otro
programa que se cortó), salta los archivos que ya están iguales y
continúa los que se quedaron a medias.
ALTERNATIVA: DESCOMPRIMIR EN EL PC (7-Zip)
En "Dónde descomprimir" elige "En el PC (7-Zip)" (o ponlo por defecto en
Ajustes). Entonces:
@@ -143,6 +162,13 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
Ajustes.
NOVEDADES 1.16
- Pestaña "Archivos de la consola": explorador con tamaños, copiar, mover,
renombrar, borrar, bajar al PC, permisos y subir aquí.
- Enviar carpetas del PC, saltando lo que ya está y continuando lo cortado.
- Botones "Elegir archivos…" y "Elegir carpeta…" (ventana de Windows).
- Pestaña "Actividad" con el historial.
NOVEDADES 1.15
- Inglés: botón "English" / "Español" arriba. La primera vez elige solo
según el idioma de Windows. La extensión del navegador también.
+2
View File
@@ -9,6 +9,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
- ✅ Send whole PC folders, skipping what's already there and resuming what got cut off
- ✅ Watches free space so the console never fills up
- ✅ Speed limit and built-in speed test
- ✅ Optional browser extension: links arrive by themselves, no copy and paste
+23 -1
View File
@@ -1,4 +1,4 @@
PS5 DIRECTO 1.15 — USER GUIDE (English)
PS5 DIRECTO 1.16 — USER GUIDE (English)
=======================================
Paste links (or drag files from your PC) and the tool does the rest:
@@ -15,6 +15,25 @@ Paste links (or drag files from your PC) and the tool does the rest:
While one transfer is extracting on the PS5, the next one in the queue is
already being sent.
TABS
- Transfers: the queue.
- Console files: browse the PS5 like a file explorer. You see the size
and date of every file (folders with "calculate"), and you can create
folders, rename, copy or move (Copy/Move, then "Paste here" in the
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.
- Activity: history of finished and failed transfers.
PC FOLDERS
With "Choose folder…" (or by dragging the folder onto the icon) a whole
folder is sent as-is. If it has sce_sys it goes to /data/homebrew as
TITLEID-app. If part of it is already on the console (for example, an
upload from another program that got cut off), it skips the files that
are already identical and resumes the ones left half-way.
ALTERNATIVE: EXTRACT ON THE PC (7-Zip)
In "Where to extract" choose "On the PC (7-Zip)" (or make it the default
in Settings). Then:
@@ -195,6 +214,9 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo
WHAT'S NEW
----------
1.16 "Console files" tab (file browser: sizes, copy, move, rename, delete,
download to PC, permissions, upload here). Send PC folders with
resume. "Choose files…/Choose folder…" buttons. "Activity" tab.
1.15 English interface (auto-detected; English/Español button). The
browser extension also follows the browser's language.
1.14 Welcome guide on first launch ("Guide" button to see it again).
+489 -7
View File
@@ -21,7 +21,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import engine as E # noqa: E402
VERSION = "1.14.1"
VERSION = "1.16.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"
@@ -112,8 +112,9 @@ def save_settings():
# Utilidades de trabajos
# ---------------------------------------------------------------------------
def new_job(lines, password, dest, title=None, where=None):
def new_job(lines, password, dest, title=None, where=None, raw=False):
return {
"raw": bool(raw),
"where": where if where in ("ps5", "pc") else "",
"id": secrets.token_hex(4),
"created": time.time(),
@@ -390,6 +391,10 @@ def transfer_job(job, srcs, cancel):
cfg = cfg_snapshot()
cfg["_log"] = lambda m: jlog(job, m)
cfg["_limit_bps"] = live_limit_bps
if job.get("raw"):
return raw_job(job, srcs, cancel)
if srcs[0].kind == "folder":
return folder_job(job, srcs[0], cancel)
names = [s.name for s in srcs]
archives = [s for s in srcs if E.is_archive(s.name)]
if archives and job_where(job) == "pc":
@@ -494,6 +499,468 @@ def transfer_job(job, srcs, cancel):
setj(job, speed=0.0, dl_speed=0.0)
# ---------------------------------------------------------------------------
# Envio "tal cual" (desde el explorador de la consola): sin descomprimir ni colocar
# ---------------------------------------------------------------------------
def raw_job(job, srcs, cancel):
cfg = cfg_snapshot()
cfg["_limit_bps"] = live_limit_bps
dest = (job.get("dest") or cfg["dest"]).rstrip("/") or "/"
total = sum(max(0, s.size) for s in srcs)
up, meter = Meter(), Meter()
state = {"base": 0, "last": 0}
stop_speed = threading.Event()
def speed_loop():
while not stop_speed.wait(1):
setj(job, speed=up.rate(), dl_speed=meter.rate())
threading.Thread(target=speed_loop, daemon=True).start()
def prog(v):
cur = state["base"] + v
if cur > state["last"]:
up(cur - state["last"])
state["last"] = cur
setj(job, sent=cur)
setj(job, status="enviando", total=total if total > 0 else -1, sent=0)
placed = []
try:
for s in srcs:
target = dest + "/" + s.name
if s.kind == "folder":
setj(job, phase="Enviando carpeta: %s" % s.name)
E.upload_tree(cfg, s, target, cancel, lambda m: jlog(job, m),
lambda v, rel: prog(v))
else:
setj(job, phase="Enviando %s" % s.name)
E.upload(cfg, target, s.size, lambda off, s=s: s.stream(off, cfg, cancel, meter),
prog, cancel, lambda m: jlog(job, m))
state["base"] += max(0, s.size)
placed.append(target)
finally:
stop_speed.set()
setj(job, speed=0.0, dl_speed=0.0)
return {"mode": "done", "placed": placed, "game": False}
# ---------------------------------------------------------------------------
# Historial (pestana Actividad)
# ---------------------------------------------------------------------------
HISTORY_FILE = os.path.join(DATA_DIR, "history.json")
HISTORY_LOCK = threading.Lock()
def record_history(job):
try:
with HISTORY_LOCK:
try:
with open(HISTORY_FILE, encoding="utf-8") as f:
hist = json.load(f)
except Exception:
hist = []
hist.append({
"t": time.time(), "id": job["id"], "title": job.get("title") or "",
"status": job.get("status"), "size": job.get("total", -1),
"result": job.get("result") or [], "error": job.get("error") or "",
"secs": int(time.time() - float(job.get("started") or job.get("created") or time.time())),
"where": "raw" if job.get("raw") else ("pc" if (job.get("info") or {}).get("mode") == "pcx" else "ps5"),
"dest": job.get("dest") or "",
})
_save_json(HISTORY_FILE, hist[-500:])
except Exception:
traceback.print_exc()
def read_history():
try:
with open(HISTORY_FILE, encoding="utf-8") as f:
return list(reversed(json.load(f)))
except Exception:
return []
# ---------------------------------------------------------------------------
# Explorador de archivos de la consola
# ---------------------------------------------------------------------------
FMOP = {"busy": False, "op": "", "current": "", "items_done": 0, "items_total": 0,
"bytes_done": 0, "bytes_total": 0, "error": "", "message": "", "t0": 0.0, "local": ""}
FM_CANCEL = threading.Event()
def fm_clean(path):
path = "/" + "/".join(x for x in str(path or "/").replace("\\", "/").split("/") if x and x != ".")
if ".." in path.split("/"):
raise E.PipeError("Ruta no valida")
return path
def fm_list(path):
cfg = cfg_snapshot()
path = fm_clean(path)
bf = E.BFpilot(cfg)
out = {"path": path, "entries": [], "free": None, "total": None, "via": "bfpilot"}
if bf.alive():
ents = bf.listdir(path)
out["entries"] = [{"name": e.get("name"), "dir": bool(e.get("dir")), "size": e.get("size"),
"mtime": e.get("mtime"), "mode": e.get("mode")} for e in ents]
try:
st = bf._req("GET", "/api/fs/stat", {"path": path})
out["free"] = st.get("availableBytes")
out["total"] = st.get("usableTotalBytes") or st.get("totalBytes")
except Exception:
pass
else:
out["via"] = "ftp"
f = E.Ftp(cfg).connect()
try:
for name, facts in f.mlsd(path, facts=["type", "size", "modify", "unix.mode"]):
if facts.get("type") not in ("file", "dir"):
continue
mt = facts.get("modify")
try:
mt = int(time.mktime(time.strptime(mt[:14], "%Y%m%d%H%M%S"))) if mt else None
except Exception:
mt = None
mode = facts.get("unix.mode")
out["entries"].append({"name": name, "dir": facts.get("type") == "dir",
"size": int(facts.get("size") or 0), "mtime": mt,
"mode": int(mode, 8) if mode else None})
finally:
try:
f.quit()
except Exception:
pass
out["entries"].sort(key=lambda e: (not e["dir"], (e["name"] or "").lower()))
return out
def fm_bf():
bf = E.BFpilot(cfg_snapshot())
if not bf.alive():
raise E.PipeError("BFpilot no responde: cargalo para copiar, mover, borrar o calcular tamanos")
return bf
def _fm_start(op, items_total, fn):
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()
def run():
try:
fn()
with LOCK:
if not FMOP["message"]:
FMOP["message"] = "Hecho"
except E.Cancelled:
with LOCK:
FMOP["error"] = "Cancelado"
except Exception as e:
traceback.print_exc()
with LOCK:
FMOP["error"] = str(e)
finally:
with LOCK:
FMOP["busy"] = False
threading.Thread(target=run, daemon=True).start()
def fm_paste(op, srcs, dst, overwrite=False):
bf = fm_bf()
dst = fm_clean(dst)
srcs = [fm_clean(x) for x in srcs]
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"))
def work():
for i, s in enumerate(srcs):
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP.update(current=s, items_done=i, bytes_done=0, bytes_total=0)
if op == "move" and posixpath.dirname(s) == dst:
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():
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()
with LOCK:
FMOP["items_done"] = len(srcs)
_fm_start(op, len(srcs), work)
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)
def work():
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)
with LOCK:
FMOP["items_done"] = len(paths)
_fm_start("delete", len(paths), work)
def fm_download(paths):
cfg = cfg_snapshot()
paths = [fm_clean(x) for x in paths]
root_local = os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo")
def walk(rpath):
"""[(ruta remota, ruta relativa, tamano)] de un archivo o carpeta."""
out = []
base = posixpath.basename(rpath)
listing = None
try:
listing = fm_list(rpath)["entries"]
except Exception:
listing = None
if listing is None:
return [(rpath, base, None)]
stack = [(rpath, base, listing)]
while stack:
cur, rel, ents = stack.pop()
for e in ents:
rp, rr = cur + "/" + e["name"], rel + "/" + e["name"]
if e["dir"]:
stack.append((rp, rr, fm_list(rp)["entries"]))
else:
out.append((rp, rr, e.get("size")))
return out
def work():
files = []
for p in paths:
parent = fm_list(posixpath.dirname(p) or "/")["entries"]
ent = next((e for e in parent if e["name"] == posixpath.basename(p)), None)
if ent and not ent["dir"]:
files.append((p, posixpath.basename(p), ent.get("size")))
else:
files.extend(walk(p))
total = sum(sz or 0 for _, _, sz in files)
with LOCK:
FMOP.update(items_total=len(files), bytes_total=total, local=root_local)
done = 0
for i, (rp, rel, sz) in enumerate(files):
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP.update(current=rp, items_done=i)
local = os.path.join(root_local, *rel.split("/"))
if sz is not None and os.path.isfile(local) and os.path.getsize(local) == sz:
done += sz
continue
base = done
E.ftp_download(cfg, rp, local, FM_CANCEL, lambda n: FMOP.update(bytes_done=base + n), sz)
done = base + (sz or 0)
with LOCK:
FMOP.update(items_done=len(files), bytes_done=done,
message="Guardado en %s" % root_local)
_fm_start("download", len(paths), work)
def fm_chmod(paths):
bf = fm_bf()
paths = [fm_clean(x) for x in paths]
def work():
changed = 0
for i, p in enumerate(paths):
with LOCK:
FMOP.update(current=p, items_done=i)
c, _, _ = bf.fix_perms(p, FM_CANCEL)
changed += c
with LOCK:
FMOP.update(items_done=len(paths), message="Permisos 777 puestos en %d elementos" % changed)
_fm_start("chmod", len(paths), work)
PICK_SCRIPT = r'''
import json, sys, os
import tkinter as tk
from tkinter import filedialog
r = tk.Tk(); r.withdraw()
try:
r.attributes("-topmost", True)
except Exception:
pass
kind, title = sys.argv[1], sys.argv[2]
if kind == "folder":
p = filedialog.askdirectory(title=title, mustexist=True)
res = [p] if p else []
else:
res = list(filedialog.askopenfilenames(title=title))
print(json.dumps([os.path.normpath(x) for x in res]))
'''
def fm_api(p, b):
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")))}
if p == "/api/fm/mkdir":
path = fm_clean(b.get("path"))
bf = E.BFpilot(cfg_snapshot())
if bf.alive():
bf.mkdir(path)
else:
E.Ftp(cfg_snapshot()).call(lambda f: f.mkd(path))
return {}
if p == "/api/fm/rename":
src, dst = fm_clean(b.get("src")), fm_clean(b.get("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))
return {}
if p == "/api/fm/paste":
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")))
return {}
if p == "/api/fm/delete":
fm_delete(b.get("paths") or [])
return {}
if p == "/api/fm/download":
fm_download(b.get("paths") or [])
return {}
if p == "/api/fm/chmod":
fm_chmod(b.get("paths") or [])
return {}
if p == "/api/fm/op":
with LOCK:
return {"op": dict(FMOP)}
if p == "/api/fm/cancel":
FM_CANCEL.set()
return {}
if p == "/api/fm/open_local":
path = FMOP.get("local") or os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo")
if os.name == "nt" and os.path.isdir(path):
os.startfile(path)
return {"path": path}
if p == "/api/pick":
return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")}
if p == "/api/history":
return {"items": read_history()}
if p == "/api/history/clear":
_save_json(HISTORY_FILE, [])
return {}
raise E.PipeError("no existe")
def pick_paths(kind, title):
"""Abre el dialogo de Windows para elegir archivos o una carpeta del PC."""
import subprocess
exe = sys.executable
if os.name == "nt" and exe.lower().endswith("pythonw.exe"):
alt = exe[:-len("pythonw.exe")] + "python.exe"
exe = alt if os.path.isfile(alt) else exe
kw = {"creationflags": 0x08000000} if os.name == "nt" else {}
p = subprocess.run([exe, "-c", PICK_SCRIPT, kind, title or ""], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=3600, **kw)
if p.returncode != 0:
raise E.PipeError("No pude abrir el selector de archivos: %s"
% (p.stderr.decode("utf-8", "replace").strip().splitlines() or ["?"])[-1])
return json.loads(p.stdout.decode("utf-8", "replace").strip() or "[]")
# ---------------------------------------------------------------------------
# Carpetas del PC: se mandan tal cual, saltando lo que ya este en la consola
# ---------------------------------------------------------------------------
def folder_job(job, src, cancel):
cfg = cfg_snapshot()
cfg["_limit_bps"] = live_limit_bps
dest = (job.get("dest") or cfg["dest"]).rstrip("/")
tid = None
pj = os.path.join(src.path, "sce_sys", "param.json")
if os.path.isfile(pj):
try:
with open(pj, encoding="utf-8", errors="replace") as fh:
tid = E.title_id(fh.read())
except Exception:
pass
tid = tid or E.title_id(src.name)
name = (tid + "-app") if tid else src.name
target = dest + "/" + name
total = src.size
jlog(job, "Carpeta con %d archivos (%s) -> %s" % (len(src.files), human(total), target))
bf = E.BFpilot(cfg)
free = free_space(bf)
if free is not None:
jlog(job, "Espacio libre en la PS5: %s" % human(free))
if free < margin_bytes():
raise E.PipeError("No hay sitio en la PS5: quedan %s libres y el margen es %s. Libera espacio "
"y pulsa Reintentar." % (human(free), human(margin_bytes())))
if tid:
warn_duplicates(job, bf, tid, exclude=target)
up = Meter()
last = {"v": 0, "t": 0.0}
stop_speed = threading.Event()
def speed_loop():
while not stop_speed.wait(1):
setj(job, speed=up.rate(), dl_speed=0.0)
threading.Thread(target=speed_loop, daemon=True).start()
def progress(v, rel):
if v > last["v"]:
up(v - last["v"])
last["v"] = v
now = time.time()
if now - last["t"] > 0.5:
last["t"] = now
setj(job, sent=v, phase="Enviando carpeta: %s" % rel[-80:])
setj(job, status="enviando", total=total, sent=0, phase="Comparando con lo que ya hay en la PS5")
watch = start_space_watch(job, bf, cancel, "el envio")
try:
sent, skipped, resumed = E.upload_tree(cfg, src, target, cancel, lambda m: jlog(job, m), progress)
finally:
stop_speed.set()
watch.set()
setj(job, speed=0.0)
setj(job, sent=total)
jlog(job, "Carpeta enviada: %d archivos nuevos, %d ya estaban iguales, %d continuados"
% (sent, skipped, resumed))
if bf.alive():
fix_game_perms(job, bf, target, cancel)
return {"mode": "done", "placed": [target], "game": bool(tid)}
# ---------------------------------------------------------------------------
# Alternativa: descomprimir en el PC con 7-Zip y mandar el contenido ya suelto
# ---------------------------------------------------------------------------
@@ -1056,6 +1523,11 @@ def _extract_job(job, info, cancel):
cfg = cfg_snapshot()
bf = E.BFpilot(cfg)
dest = job["dest"] or cfg["dest"]
if info["mode"] == "done":
for p in info.get("placed") or []:
jlog(job, "Colocado: %s" % p)
job["_placed_game"] = bool(info.get("game"))
return info.get("placed") or []
if info["mode"] == "pcx":
return finish_pc_extract(job, info, bf, dest, cancel, cfg)
wait_bfpilot(job, bf, cancel)
@@ -1271,7 +1743,7 @@ def transfer_worker():
idx = JOBS.index(job)
for k, extra in enumerate(sets[1:], 1):
nj = new_job([s.original for s in extra], job["password"], job["dest"],
where=job.get("where"))
where=job.get("where"), raw=job.get("raw"))
nj["sources"] = describe(extra)
nj["title"] = E.display_name(extra[0].name)
JOBS.insert(idx + k, nj)
@@ -1296,6 +1768,7 @@ def transfer_worker():
msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free)
setj(job, status="error", error=msg, phase="Error")
jlog(job, "ERROR: %s" % msg)
record_history(job)
traceback.print_exc()
save_jobs()
@@ -1318,6 +1791,7 @@ def extract_worker():
phase=("Listo. ShadowMountPlus lo anadira al inicio en unos segundos" if is_game
else "Listo. Copiado en la PS5"))
jlog(job, "Terminado")
record_history(job)
except E.Cancelled:
stopped(job)
except Exception as e:
@@ -1328,6 +1802,7 @@ def extract_worker():
msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free)
setj(job, status="error", error=msg, phase="Error")
jlog(job, "ERROR: %s" % msg)
record_history(job)
traceback.print_exc()
save_jobs()
@@ -1874,6 +2349,14 @@ 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 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:
return self._send(200, {"ok": False, "error": str(e)})
except Exception as e:
traceback.print_exc()
return self._send(200, {"ok": False, "error": str(e)})
if p == "/api/fixperms":
try:
return self._send(200, {"ok": True, "results": fix_all_perms()})
@@ -1913,7 +2396,8 @@ class Handler(BaseHTTPRequestHandler):
dest = (b.get("dest") or SETTINGS["dest"]).rstrip("/") or "/data/homebrew"
if not dest.startswith("/data") and not dest.startswith("/mnt/"):
return self._send(400, {"ok": False, "error": "El destino debe estar en /data o /mnt"})
job = new_job(lines, (b.get("password") or "").strip(), dest, where=b.get("where"))
job = new_job(lines, (b.get("password") or "").strip(), dest, where=b.get("where"),
raw=bool(b.get("raw")))
with WAKE:
JOBS.append(job)
WAKE.notify_all()
@@ -2067,10 +2551,8 @@ def dropped_paths():
if a.startswith("--"):
continue
a = a.strip().strip('"')
if os.path.isfile(a):
if os.path.isfile(a) or os.path.isdir(a):
out.append(os.path.abspath(a))
elif os.path.isdir(a):
print("Es una carpeta, la salto (arrastra archivos): %s" % a)
return out
+174 -3
View File
@@ -495,13 +495,184 @@ class FileSource:
yield data
class FolderSource:
"""Una carpeta del PC: se manda entera, archivo a archivo, conservando la estructura."""
kind = "folder"
def __init__(self, path):
self.path = os.path.abspath(path.strip().strip('"'))
self.original = self.path
self.name = safe_name(os.path.basename(self.path.rstrip("\\/")) or "carpeta")
self.size = -1
self.ranges = True
self.files = [] # [(ruta relativa con /, tamano)]
self.dirs = []
def probe(self):
if not os.path.isdir(self.path):
raise PipeError("No existe la carpeta: %s" % self.path)
files, dirs = [], []
for root, ds, fs in os.walk(self.path):
ds.sort()
rel_root = os.path.relpath(root, self.path).replace("\\", "/")
rel_root = "" if rel_root == "." else rel_root
for d in ds:
dirs.append((rel_root + "/" + d).lstrip("/"))
for f in sorted(fs):
full = os.path.join(root, f)
try:
files.append(((rel_root + "/" + f).lstrip("/"), os.path.getsize(full)))
except OSError:
pass
self.files, self.dirs = files, dirs
self.size = sum(sz for _, sz in files)
return self
def make_source(text):
text = text.strip().strip('"')
if re.match(r"^https?://", text, re.I):
return UrlSource(text)
if os.path.isdir(text):
return FolderSource(text)
return FileSource(text)
def ftp_sizes(f, path):
"""{nombre: tamano} de los archivos de una carpeta remota (None si no existe)."""
try:
return _ftp_sizes(f, path)
finally:
try:
f.voidcmd("TYPE I") # MLSD/LIST dejan la conexion en ASCII y REST fallaria
except Exception:
pass
def _ftp_sizes(f, path):
out = {}
try:
for name, facts in f.mlsd(path, facts=["type", "size"]):
if facts.get("type") == "file":
out[name] = int(facts.get("size") or 0)
return out
except ftplib.error_perm as e:
if str(e).startswith("550"):
return None
except Exception:
pass
# servidor sin MLSD: LIST y leer el tamano de cada linea
lines = []
try:
f.retrlines("LIST " + path, lines.append)
except ftplib.error_perm:
return None
for ln in lines:
parts = ln.split(None, 8)
if len(parts) == 9 and not ln.startswith("d"):
try:
out[parts[8]] = int(parts[4])
except ValueError:
pass
return out
def upload_tree(cfg, src, remote_root, cancel, log, progress):
"""Manda una FolderSource a remote_root. Salta los archivos que ya estan con el mismo
tamano, continua los que se cortaron (mas pequenos) y vuelve a mandar los demas.
progress(bytes_hechos, ruta_actual). Devuelve (enviados, saltados, continuados)."""
by_dir = {}
for rel, size in src.files:
d, _, n = rel.rpartition("/")
by_dir.setdefault(d, []).append((n, size, rel))
done = 0
sent = skipped = resumed = 0
w = _FtpWriter(cfg, cancel, log)
try:
w.mkdir(remote_root)
for d in src.dirs:
w.mkdir(remote_root + "/" + d)
for d in sorted(by_dir):
rdir = remote_root + ("/" + d if d else "")
w.mkdir(rdir)
have = w._retry(lambda: ftp_sizes(w.f, rdir)) or {}
for name, size, rel in by_dir[d]:
if cancel.is_set():
raise Cancelled()
rpath = rdir + "/" + name
got = have.get(name)
if got == size:
skipped += 1
done += size
progress(done, rel)
continue
offset = got if (got is not None and 0 < got < size) else 0
if offset:
resumed += 1
log("Continuando %s desde %s MB" % (rel, offset // MiB))
local = os.path.join(src.path, *rel.split("/"))
base = done
w.begin(rpath, offset)
with open(local, "rb") as fh:
fh.seek(offset)
pos = offset
while True:
if cancel.is_set():
raise Cancelled()
chunk = fh.read(4 * MiB)
if not chunk:
break
w.write(chunk)
pos += len(chunk)
progress(base + pos, rel)
w.end(size)
done = base + size
sent += 1
return sent, skipped, resumed
finally:
w.close()
def ftp_download(cfg, remote, local, cancel, progress, size=None):
"""Baja un archivo de la PS5 al PC (continua si ya hay un .part)."""
os.makedirs(os.path.dirname(local) or ".", exist_ok=True)
part = local + ".part"
retries = int(cfg.get("retries", 8))
for attempt in range(retries + 1):
if cancel.is_set():
raise Cancelled()
off = os.path.getsize(part) if os.path.isfile(part) else 0
f = None
try:
f = Ftp(cfg).connect()
with open(part, "ab") as fh:
state = {"n": off}
def cb(data):
if cancel.is_set():
raise Cancelled()
fh.write(data)
state["n"] += len(data)
progress(state["n"])
f.retrbinary("RETR " + remote, cb, blocksize=MiB, rest=off or None)
if size is not None and os.path.getsize(part) != size:
raise PipeError("descarga incompleta")
os.replace(part, local)
return
except Cancelled:
raise
except Exception as e:
if attempt >= retries:
raise PipeError("No pude bajar %s: %s" % (remote, e))
time.sleep(min(30, 2 * (attempt + 1)))
finally:
if f is not None:
try:
f.close()
except Exception:
pass
def concat_stream(parts, offset, cfg, cancel, meter):
"""Lee varias fuentes como si fueran un unico archivo (para unir .001 .002 ...)."""
base = 0
@@ -1052,13 +1223,13 @@ class _FtpWriter:
pass
return c
def begin(self, path):
def begin(self, path, offset=0):
self.path = path
self.sent = 0
self.sent = offset
self.tail = deque()
self.tail_len = 0
self.mkdir(path.rsplit("/", 1)[0] or "/")
self.conn = self._retry(lambda: self._open_data(path, 0))
self.conn = self._retry(lambda: self._open_data(path, offset))
def _remember(self, data):
self.tail.append(data)
+3 -2
View File
@@ -1,3 +1,4 @@
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
// PS5 Directo - captura de enlaces.
// Cuando el navegador empieza a descargar un archivo comprimido o paquete (.rar, .7z, .zip, .pkg...),
// manda el enlace FINAL (despues de redirecciones) a PS5 Directo y cancela la descarga.
@@ -58,8 +59,8 @@ async function handle(item) {
chrome.action.setBadgeBackgroundColor({ color: "#2563eb" });
chrome.action.setBadgeText({ text: String(n % 1000) });
chrome.notifications.create({
type: "basic", iconUrl: "icon128.png", title: "Enviado a PS5 Directo",
message: (name || "Enlace") + " — no se descarga en el PC."
type: "basic", iconUrl: "icon128.png", title: EN ? "Sent to PS5 Directo" : "Enviado a PS5 Directo",
message: (name || (EN ? "Link" : "Enlace")) + (EN ? " — not downloaded to the PC." : " — no se descarga en el PC.")
});
}
+2 -2
View File
@@ -11,10 +11,10 @@ button{background:#1f2937;color:#e6edf3;border:1px solid #374151;border-radius:6
</style></head>
<body>
<h1>PS5 Directo</h1>
<div class="row"><label for="en">Capturar enlaces</label><input type="checkbox" id="en"></div>
<div class="row"><label for="en" id="lblEn">Capturar enlaces</label><input type="checkbox" id="en"></div>
<div class="st" id="st">Comprobando…</div>
<div class="st" id="cnt"></div>
<div class="row"><span></span><button id="reset">Poner contador a 0</button></div>
<small>Pasa el captcha y pulsa Descargar como siempre: si es un .rar, .7z, .zip, .pkg… y PS5 Directo está abierto, el enlace va a su cuadro de enlaces y el navegador no descarga nada. Si PS5 Directo está cerrado, la descarga sigue normal.</small>
<small id="help">Pasa el captcha y pulsa Descargar como siempre: si es un .rar, .7z, .zip, .pkg… y PS5 Directo está abierto, el enlace va a su cuadro de enlaces y el navegador no descarga nada. Si PS5 Directo está cerrado, la descarga sigue normal.</small>
<script src="popup.js"></script>
</body></html>
+19 -3
View File
@@ -1,3 +1,19 @@
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
const T = EN ? {
capture: "Capture links", checking: "Checking…", reset: "Reset counter",
help: "Pass the captcha and click Download as usual: if it's a .rar, .7z, .zip, .pkg… and PS5 Directo is open, the link goes to its link box and the browser downloads nothing. If PS5 Directo is closed, the download goes ahead normally.",
sent: "Sent", last: "last", connected: "● Connected", to: "to PS5 Directo", closed: "● PS5 Directo is not open", normal: "(downloads will work normally)"
} : {
capture: "Capturar enlaces", checking: "Comprobando…", reset: "Poner contador a 0",
help: null, sent: "Enviados", last: "último", connected: "● Conectado", to: "a PS5 Directo", closed: "● PS5 Directo no está abierto", normal: "(las descargas seguirán normales)"
};
if (EN) {
document.documentElement.lang = "en";
document.getElementById("lblEn").textContent = T.capture;
document.getElementById("st").textContent = T.checking;
document.getElementById("reset").textContent = T.reset;
document.getElementById("help").textContent = T.help;
}
async function findApp() {
for (const port of [8777, 8778, 8779]) {
try {
@@ -13,7 +29,7 @@ async function findApp() {
en.checked = cfg.enabled;
en.onchange = () => chrome.storage.local.set({ enabled: en.checked });
document.getElementById("cnt").textContent =
cfg.captured ? `Enviados: ${cfg.captured}${cfg.last ? " (último: " + cfg.last + ")" : ""}` : "";
cfg.captured ? `${T.sent}: ${cfg.captured}${cfg.last ? " (" + T.last + ": " + cfg.last + ")" : ""}` : "";
document.getElementById("reset").onclick = async () => {
await chrome.storage.local.set({ captured: 0, last: "" });
chrome.action.setBadgeText({ text: "" });
@@ -21,6 +37,6 @@ async function findApp() {
};
const app = await findApp();
const st = document.getElementById("st");
if (app) { st.innerHTML = `<span class="ok">● Conectado</span> a PS5 Directo v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
else st.innerHTML = '<span class="bad">● PS5 Directo no está abierto</span> (las descargas seguirán normales)';
if (app) { st.innerHTML = `<span class="ok">${T.connected}</span> ${T.to} v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
else st.innerHTML = `<span class="bad">${T.closed}</span> ${T.normal}`;
})();
+358 -9
View File
@@ -64,6 +64,48 @@ dialog .head h2{margin:0;font-size:16px}
dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
.check{display:flex;gap:9px;align-items:center;margin-top:12px;font-size:14px;color:var(--text)}
.check input{width:auto}
/* --- pestanas --- */
.tabs{display:flex;gap:4px;max-width:1060px;margin:14px auto 0;padding:0 20px}
.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)}
.pickrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
/* --- explorador --- */
.fm-head{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.fm-head h2{margin:0}
.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}
.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}
.fm-bar #fmSearch{width:170px}
.fm-bar #fmPlaces{width:150px}
.iconbtn{padding:6px 10px;min-width:34px}
.fm-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;align-items:center}
.fm-actions .sep{width:1px;height:22px;background:var(--line);margin:0 4px}
.fm-actions button[disabled]{opacity:.4;cursor:default}
.clip,.fmop{margin-top:10px;padding:9px 12px;border:1px solid #2f5490;background:rgba(61,139,253,.08);border-radius:9px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:13.5px}
.fmop .bar{flex:1;min-width:160px;margin:0}
.fmtable{width:100%;border-collapse:collapse;margin-top:12px;font-size:13.5px}
.fmtable th{color:var(--muted);font-weight:600;font-size:12px;text-align:left;padding:8px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;white-space:nowrap}
.fmtable td{padding:7px 8px;border-bottom:1px solid rgba(42,50,61,.6);vertical-align:middle}
.fmtable tr:hover td{background:rgba(255,255,255,.025)}
.fmtable tr.sel td{background:rgba(61,139,253,.12)}
.fmtable td.num,.fmtable th.num{text-align:right;white-space:nowrap}
.fmtable td.mono{font-family:Consolas,"Cascadia Mono",monospace;color:var(--muted);font-size:12.5px}
.fmtable input[type=checkbox]{width:auto}
.fname{display:flex;align-items:center;gap:8px;cursor:pointer}
.fname svg{flex:none;opacity:.85}
.fname.dir span{color:#cfe0ff}
.linkbtn{background:none;border:none;padding:0;color:var(--accent);cursor:pointer;font-size:12.5px}
.rowact{display:flex;gap:4px;justify-content:flex-end;opacity:.5}
tr:hover .rowact{opacity:1}
.rowact button{padding:3px 8px;font-size:12px}
.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}
@media(max-width:720px){.fmtable .hide-s{display:none}}
/* --- guia de bienvenida --- */
.tour-spot{position:fixed;z-index:50;border-radius:12px;box-shadow:0 0 0 9999px rgba(5,8,14,.72),0 0 0 2px var(--accent),0 0 24px rgba(61,139,253,.55);transition:all .25s ease;pointer-events:none}
.tour-spot.none{box-shadow:0 0 0 9999px rgba(5,8,14,.72);width:0!important;height:0!important}
@@ -92,26 +134,37 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
<header>
<svg width="40" height="40" style="flex:none;filter:drop-shadow(0 4px 12px rgba(75,79,230,.45))" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <defs> <linearGradient id="lg_bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0" stop-color="#3a7bff"/> <stop offset=".55" stop-color="#4b4fe6"/> <stop offset="1" stop-color="#7b3fe4"/> </linearGradient> <radialGradient id="lg_glow" cx=".3" cy=".2" r=".9"> <stop offset="0" stop-color="#fff" stop-opacity=".35"/> <stop offset=".5" stop-color="#fff" stop-opacity="0"/> </radialGradient> <linearGradient id="lg_arrow" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#ffffff"/> <stop offset="1" stop-color="#dfe6ff"/> </linearGradient> </defs> <rect x="8" y="8" width="240" height="240" rx="58" fill="url(#lg_bg)"/> <rect x="8" y="8" width="240" height="240" rx="58" fill="url(#lg_glow)"/> <rect x="9.5" y="9.5" width="237" height="237" rx="56.5" fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="3"/> <g stroke="#fff" stroke-linecap="round" stroke-opacity=".45" stroke-width="9"> <line x1="62" y1="58" x2="62" y2="92"/> <line x1="194" y1="58" x2="194" y2="92"/> </g> <path d="M128 44 c9 0 15 6 15 15 v58 h22 c11 0 16 13 8 21 l-35 37 c-6 6-14 6-20 0 l-35-37 c-8-8-3-21 8-21 h22 v-58 c0-9 6-15 15-15z" fill="url(#lg_arrow)"/> <path d="M52 168 v18 c0 14 10 24 24 24 h104 c14 0 24-10 24-24 v-18" fill="none" stroke="#fff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/> <circle cx="176" cy="190" r="5.5" fill="#7dffb2"/> </svg>
<div>
<h1>PS5 Directo <span id="ver" style="font-size:12px;font-weight:400;opacity:.55"></span></h1>
<h1>PS5 Directo <span id="ver" data-noi18n style="font-size:12px;font-weight:400;opacity:.55"></span></h1>
<div class="sub">Envía archivos a tu consola por la red local, sin pasar por el disco del PC</div>
</div>
<div class="spacer"></div>
<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" 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>
<button class="small" id="btnUpdHead" title="Buscar o instalar una versión nueva">Actualizar</button>
<button class="small" id="btnSettings">Ajustes</button>
<button class="small" id="btnQuit" title="Cierra PS5 Directo del todo">Salir</button>
</header>
<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="Activity">Actividad</button>
</nav>
<main>
<main id="tabTransfers">
<section class="card">
<h2>Nueva transferencia</h2>
<label for="lines">Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…</label>
<textarea id="lines" placeholder="https://servidor/archivo.part1.rar&#10;https://servidor/archivo.part2.rar&#10;&#10;También vale la ruta de un archivo de tu PC, por ejemplo: D:\Descargas\archivo.rar"></textarea>
<div class="drophint" id="drophint">Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el <b>icono de PS5 Directo</b>, o clic derecho → <b>Enviar a → PS5 Directo</b>.</div>
<div class="pickrow">
<button class="small" id="btnPickFiles">Elegir archivos…</button>
<button class="small" id="btnPickFolder">Elegir carpeta…</button>
<span class="hint">Una carpeta se manda tal cual (si tiene sce_sys, como aplicación) y salta lo que ya esté igual en la consola.</span>
</div>
<div class="row tri">
<div>
<label for="pw">Contraseña del comprimido (si la tiene)</label>
@@ -146,6 +199,61 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
</section>
</main>
<main id="tabFiles" hidden>
<section class="card">
<div class="fm-head">
<h2>Archivos de la consola</h2>
<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>
<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="tabActivity" hidden>
<section class="card">
<div class="fm-head"><h2>Actividad</h2><div class="spacer"></div><button class="small" id="actClear">Borrar historial</button></div>
<p class="hint">Historial de transferencias terminadas o con error (las últimas 500).</p>
<table class="acttable"><thead><tr><th>Fecha</th><th>Nombre</th><th>Resultado</th><th class="num">Tamaño</th><th>Duración</th><th>Destino</th></tr></thead>
<tbody id="actBody"></tbody></table>
</section>
</main>
<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">
@@ -235,6 +343,65 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
</dialog>
<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
// --- 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'})();
document.documentElement.lang=LANG;
const I18N_RXC=I18N_RX.map(([a,b])=>[new RegExp(a),b]);
const I18N_CACHE=new Map();
function trCore(c){
if(I18N_EXACT[c]!==undefined) return I18N_EXACT[c];
for(const [rx,en] of I18N_RXC){const m=rx.exec(c); if(m) return en.replace(/\{(\d)\}/g,(_,i)=>tr(m[+i+1]||''));}
return null;
}
function tr(s){
if(LANG!=='en'||s==null) return s;
s=String(s); if(!/[a-záéíóúñ¿¡]/i.test(s)) return s;
if(I18N_CACHE.has(s)) return I18N_CACHE.get(s);
const lead=s.match(/^\s*(?:[:·—]\s+)?/)[0], trail=s.match(/\s*$/)[0];
const core=s.slice(lead.length, s.length-trail.length);
let r=core?trCore(core):'';
if(r==null){
if(core.includes('\n')) r=core.split('\n').map(trLine).join('\n');
else{
for(const sep of [' -- ',' · ']){ if(core.includes(sep)){ r=core.split(sep).map(tr).join(sep); break } }
if(r==null){ const parts=core.split(/(?<=[.!?:])\s+(?=[A-ZÁÉÍÓÚ¿¡(])/); if(parts.length>1){ const t=parts.map(tr); r=t.join(' ') } }
}
}
const out=lead+(r==null?core:r)+trail;
if(I18N_CACHE.size>5000) I18N_CACHE.clear();
I18N_CACHE.set(s,out); return out;
}
function trLine(l){const m=/^(\d\d:\d\d:\d\d )([\s\S]*)$/.exec(l); return m?m[1]+tr(m[2]):tr(l)}
const I18N_SKIP={SCRIPT:1,STYLE:1,TEXTAREA:1};
function trText(n){
const p=n.parentNode; if(!p||I18N_SKIP[p.nodeName]||(p.closest&&p.closest('[data-noi18n]'))) return;
const v=n.nodeValue; const nv=(p.nodeName==='PRE')?v.split('\n').map(trLine).join('\n'):tr(v);
if(nv!==v) n.nodeValue=nv;
}
function trAttrs(el){ for(const a of ['placeholder','title']){ const v=el.getAttribute&&el.getAttribute(a); if(v){ const nv=tr(v); if(nv!==v) el.setAttribute(a,nv) } } }
function trTree(root){
if(LANG!=='en'||!root) return;
if(root.nodeType===3){trText(root);return}
if(root.nodeType!==1||I18N_SKIP[root.nodeName]||(root.closest&&root.closest('[data-noi18n]'))) return;
trAttrs(root); root.querySelectorAll('[placeholder],[title]').forEach(trAttrs);
const w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let n; while((n=w.nextNode())) trText(n);
}
if(LANG==='en'){
trTree(document.body);
new MutationObserver(ms=>{for(const m of ms){
if(m.type==='characterData') trText(m.target);
else if(m.type==='attributes') trAttrs(m.target);
else m.addedNodes.forEach(trTree);
}}).observe(document.body,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['placeholder','title']});
const _c=window.confirm.bind(window), _p=window.prompt.bind(window);
window.confirm=m=>_c(tr(m)); window.prompt=(m,d)=>_p(tr(m),d);
}
function setLang(l){try{localStorage.setItem('ps5d_lang',l)}catch(e){} location.reload()}
</script>
<script>
const TOKEN = new URLSearchParams(location.hash.slice(1)).get('t') || '';
@@ -290,7 +457,7 @@ function render(){
const canPause = ['pendiente','comprobando','enviando'].includes(j.status);
const paused = j.status==='pausado';
return `<div class="job" data-id="${j.id}">
<div class="top"><span class="title">${esc(j.title|| (j.sources&&j.sources[0]&&j.sources[0].name) || 'Transferencia')}</span>
<div class="top"><span class="title" data-noi18n>${esc(j.title|| (j.sources&&j.sources[0]&&j.sources[0].name) || 'Transferencia')}</span>
<span class="chip ${esc(j.status.split(' ')[0])}">${esc(j.status)}</span><div class="spacer"></div>
${canPause?`<button class="small" data-act="pause">Pausar</button>`:''}
${paused?`<button class="small" data-act="retry">Reanudar</button>`:''}
@@ -307,9 +474,9 @@ function render(){
${showEx?`<div class="bar ex"><i style="width:${j.extract_pct||0}%"></i></div><div class="stats"><span>Descompresión en la PS5 <b>${j.extract_pct||0}%</b></span><span>${esc(j.extract_text||'')}</span></div>`:''}
${j.warning?`<div class="warn">${esc(j.warning)}</div>`:''}
${j.error?`<div class="err">${esc(j.error)}</div>`:''}
${(j.result||[]).length?`<div class="result">${j.result.map(esc).join('<br>')}</div>`:''}
${(j.result||[]).length?`<div class="result" data-noi18n>${j.result.map(esc).join('<br>')}</div>`:''}
<details ${openLogs.has(j.id)?'open':''} data-log="${j.id}"><summary>Archivos y registro</summary>
<div class="hint" style="margin-top:6px">${files||'—'}</div>
<div class="hint" style="margin-top:6px" data-noi18n>${files||'—'}</div>
<pre class="log">${esc((j.log||[]).slice(-120).join('\n'))}</pre>
<button class="small" data-act="copylog" style="margin-top:6px">Copiar registro</button></details>
</div>`}).join('');
@@ -384,6 +551,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:'#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;
@@ -398,10 +566,10 @@ function tourPlace(){
if(!tourEls) return;
const [blk,spot,box]=tourEls, st=TOUR[tourI];
const el=st.t&&document.querySelector(st.t);
box.querySelector('h3').textContent=st.h; box.querySelector('p').textContent=st.p;
box.querySelector('.tb-step').textContent=(tourI+1)+' de '+TOUR.length;
box.querySelector('h3').textContent=tr(st.h); box.querySelector('p').textContent=tr(st.p);
box.querySelector('.tb-step').textContent=tr((tourI+1)+' de '+TOUR.length);
box.querySelector('[data-t=back]').hidden=tourI===0;
box.querySelector('[data-t=next]').textContent=tourI===TOUR.length-1?'Empezar':'Siguiente';
box.querySelector('[data-t=next]').textContent=tr(tourI===TOUR.length-1?'Empezar':'Siguiente');
const bw=box.offsetWidth, bh=box.offsetHeight, W=innerWidth, H=innerHeight;
if(!el){spot.className='tour-spot none';spot.style.left=W/2+'px';spot.style.top=H/2+'px';
box.style.left=Math.max(16,(W-bw)/2)+'px';box.style.top=Math.max(16,(H-bh)/2)+'px';return}
@@ -438,6 +606,8 @@ function startTour(onDone){
}
function needIp(){ if(state.settings && !state.settings.ps5_ip){$('btnSettings').click();setTimeout(()=>{try{$('s_ps5_ip').focus()}catch(e){}},100);toast('Pon la IP de tu PS5 y pulsa Guardar')} }
$('btnTour').onclick=()=>startTour();
$('btnLang').textContent=LANG==='en'?'Español':'English';
$('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(()=>{
@@ -548,7 +718,7 @@ function notifyChanges(){
if(j.status==='hecho'||j.status==='error'){
const title=j.status==='hecho'?'Listo en la PS5':'Problema en PS5 Directo';
const body=(j.title||'Transferencia')+(j.status==='error'?': '+(j.error||'').slice(0,120):'');
try{ if('Notification' in window && Notification.permission==='granted') new Notification(title,{body,icon:'/favicon.ico'}); }catch(e){}
try{ if('Notification' in window && Notification.permission==='granted') new Notification(tr(title),{body:tr(body),icon:'/favicon.ico'}); }catch(e){}
}
}
}
@@ -622,5 +792,184 @@ function afterUpdate(v){
document.body.innerHTML='<div style="padding:40px;font-family:system-ui,sans-serif;color:#ddd;background:#0d1117;min-height:100vh"><h2>Actualizado a la v'+esc(v)+'</h2><p>PS5 Directo se está reiniciando y abrirá una ventana nueva en unos segundos. Ya puedes cerrar esta.</p></div>';
}
</script>
<script>
// ===================== pestañas =====================
let curTab='Transfers';
function showTab(t){
curTab=t;
['Transfers','Files','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();
}
document.querySelectorAll('#tabs button').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
// ===================== elegir archivos / carpeta del PC =====================
async function pick(kind){
const title=kind==='folder'?tr('Elige la carpeta que quieres enviar'):tr('Elige los archivos que quieres enviar');
toast(tr('Se ha abierto la ventana para elegir (puede salir detrás)'));
const r=await api('/api/pick',{kind,title});
if(!r.ok){toast(r.error);return []}
return r.paths||[];
}
$('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))}};
// ===================== 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 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>';
function joinPath(a,b){return (a.replace(/\/+$/,'')+'/'+b).replace(/\/+/g,'/')}
function parentOf(p){const x=p.replace(/\/+$/,'').split('/');x.pop();return x.join('/')||'/'}
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)}
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.'):'';
fmRender();
}
function fmRender(){
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');
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':''}">
<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 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('');
$('fmAll').checked=list.length>0&&list.every(e=>fm.sel.has(joinPath(fm.path,e.name)));
fmButtons();
}
function fmButtons(){
const n=fm.sel.size;
['fmCopy','fmCut','fmDownload','fmSize','fmPerms','fmDelete'].forEach(id=>$(id).disabled=n===0||fm.busy);
$('fmRename').disabled=n!==1||fm.busy;
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()}}
else c.hidden=true;
}
$('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(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();
});
$('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()};
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());
$('fmMkdir').onclick=async()=>{const n=prompt(tr('Nombre de la carpeta nueva:'),''); if(!n) return;
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 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…':'');
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;
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'};
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()}
async function fmPollOp(){
let r; try{r=await api('/api/fm/op',{})}catch(e){return}
const o=r.op, box=$('fmOp');
if(!o.op){box.hidden=true;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>
<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>`;
$('fmOpOk').onclick=()=>{box.hidden=true};
if($('fmOpenLocal')) $('fmOpenLocal').onclick=()=>api('/api/fm/open_local',{});
if(o.op!=='download') fmGo(fm.path,true);
}
}
fmWatch();
// ===================== actividad =====================
async function loadActivity(){
let r; try{r=await api('/api/history',{})}catch(e){return}
const items=r.items||[];
$('actBody').innerHTML=items.length?items.map(h=>{
const ok=h.status==='hecho', d=new Date(h.t*1000), m=Math.floor((h.secs||0)/60);
const dur=h.secs>=3600?Math.floor(h.secs/3600)+' h '+(m%60)+' min':(m?m+' min '+(h.secs%60)+' s':(h.secs||0)+' s');
return `<tr><td class="mono">${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
<td data-noi18n>${esc(h.title)}</td>
<td><span class="chip ${ok?'hecho':'error'}">${esc(h.status)}</span>${h.error?`<div class="hint">${esc(h.error.slice(0,160))}</div>`:''}</td>
<td class="num">${h.size>0?human(h.size):''}</td><td>${dur}</td>
<td class="mono" data-noi18n>${esc((h.result||[]).join(', ')||h.dest||'')}</td></tr>`}).join('')
:`<tr><td colspan="6" class="hint" style="padding:18px;text-align:center">${esc(tr('Todavía no hay nada en el historial.'))}</td></tr>`;
}
$('actClear').onclick=async()=>{if(!confirm(tr('¿Borrar todo el historial?')))return; await api('/api/history/clear',{}); loadActivity()};
try{const t=localStorage.getItem('ps5d_tab'); if(t&&t!=='Transfers'&&$('tab'+t)) showTab(t)}catch(e){}
</script>
</body>
</html>