From 4fc970e7d8f09eab6627b391f4e5d92b6684f6c3 Mon Sep 17 00:00:00 2001 From: JesterDev <105895386+jstrdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:04 +0200 Subject: [PATCH] Version 1.16.0 --- LEEME.txt | 28 ++- README.md | 2 + README_EN.txt | 24 ++- app.py | 496 ++++++++++++++++++++++++++++++++++++++++++- engine.py | 177 ++++++++++++++- extension/bg.js | 5 +- extension/popup.html | 4 +- extension/popup.js | 22 +- web/index.html | 367 +++++++++++++++++++++++++++++++- 9 files changed, 1097 insertions(+), 28 deletions(-) diff --git a/LEEME.txt b/LEEME.txt index bbd49f4..8dec95d 100644 --- a/LEEME.txt +++ b/LEEME.txt @@ -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. diff --git a/README.md b/README.md index 48c448f..e366007 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_EN.txt b/README_EN.txt index f9b69a3..c2acda0 100644 --- a/README_EN.txt +++ b/README_EN.txt @@ -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). diff --git a/app.py b/app.py index a1b2008..ce29f1f 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/engine.py b/engine.py index 61eabfa..538e763 100644 --- a/engine.py +++ b/engine.py @@ -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) diff --git a/extension/bg.js b/extension/bg.js index d8098fa..045c53b 100644 --- a/extension/bg.js +++ b/extension/bg.js @@ -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.") }); } diff --git a/extension/popup.html b/extension/popup.html index bc164ea..d6b9114 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -11,10 +11,10 @@ button{background:#1f2937;color:#e6edf3;border:1px solid #374151;border-radius:6

PS5 Directo

-
+
Comprobando…
-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. +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. diff --git a/extension/popup.js b/extension/popup.js index a493dc3..273579c 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -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 = `● Conectado a PS5 Directo v${app.version}`; chrome.action.setBadgeText({ text: "" }); } - else st.innerHTML = '● PS5 Directo no está abierto (las descargas seguirán normales)'; + if (app) { st.innerHTML = `${T.connected} ${T.to} v${app.version}`; chrome.action.setBadgeText({ text: "" }); } + else st.innerHTML = `${T.closed} ${T.normal}`; })(); diff --git a/web/index.html b/web/index.html index 5c3dd90..44c0472 100644 --- a/web/index.html +++ b/web/index.html @@ -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}
-

PS5 Directo

+

PS5 Directo

Envía archivos a tu consola por la red local, sin pasar por el disco del PC
FTP BFpilot +
+ -
+

Nueva transferencia

Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el icono de PS5 Directo, o clic derecho → Enviar a → PS5 Directo.
+
+ + + Una carpeta se manda tal cual (si tiene sce_sys, como aplicación) y salta lo que ya esté igual en la consola. +
@@ -146,6 +199,61 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
+
+
+
+

Archivos de la consola

+
+
+
+
+ + + + + + + +
+
+ + + + + + + + + + + +
+ + + + + + + + + + + + +
NombreTamañoModificadoPermisos
+

+
+
+ +
+
+

Actividad

+

Historial de transferencias terminadas o con error (las últimas 500).

+ +
FechaNombreResultadoTamañoDuraciónDestino
+
+
+

Test de velocidad

@@ -235,6 +343,65 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
+ +