diff --git a/release.yml b/.github/workflows/release.yml similarity index 100% rename from release.yml rename to .github/workflows/release.yml diff --git a/download b/.gitignore similarity index 100% rename from download rename to .gitignore diff --git a/LEEME.txt b/LEEME.txt index c559f44..3029e22 100644 --- a/LEEME.txt +++ b/LEEME.txt @@ -300,6 +300,74 @@ instalado) y PS5Directo-X.Y.Z-portable-windows.zip, que trae Python dentro: descomprimir y abrir, sin instalar nada. +NOVEDADES 1.20.3 +- Biblioteca: «Comprobar» en cada aplicación, para cuando no abre o se + cierra al arrancar. Revisa la carpeta (ejecutable, param.json, permisos, + firmware, backports y el empaquetado LZ4), muestra los registros (.log) y + las capturas de los últimos minutos, explica el código de error que salió + en la tele y guarda un informe .zip. Solo lee: no cambia nada. +- Ventana de bienvenida la primera vez: resume qué es PS5 Directo y qué hace. +- Biblioteca: también lo instalado en la consola (PS4 y PS5), con portada, + versión y firmware, y filtro para verlo. +- Biblioteca, «En tu PC»: eliges carpetas del PC o del NAS, marca lo que ya + está en la consola y lo que no, y lo envías con un clic. +- Biblioteca: «Ver actualizaciones» abre en el navegador la lista pública de + actualizaciones oficiales de cada título (Prospero Patches). Avisa de que + las que piden firmware 14.00 o más no se pueden instalar. El programa no + descarga nada. +- Biblioteca: si tienes PS5 Game Updater en la consola, un botón lo abre; si + su carpeta está mal colocada, lo detecta y la coloca bien. +- Biblioteca: las imágenes y las carpetas sin param.json salen con nombre, + portada y firmware. +- Nueva pestaña «Copia de seguridad»: guarda en el PC las partidas, los + ajustes de ShadowMountPlus y de los payloads y la base de datos de + aplicaciones, a mano o una vez al día. Las partidas se pueden restaurar en + la misma consola (antes guarda las de ahora). +- Nueva pestaña «Capturas»: capturas y vídeos de la consola con miniaturas, + para verlos y bajarlos al PC. +- «Diagnóstico» en las transferencias con error: qué ha pasado, códigos y cómo + arreglarlo, y un informe .zip (sin contraseñas) para pasar a quien te ayude. + En Ajustes, «Crear informe de fallo». +- Torrents: pega un magnet o un .torrent. Se baja primero al PC con aria2 (se + descarga solo) y luego se envía, descomprime o convierte. No comparte al + terminar y lo borra del PC. +- PS5 Dump Forge se actualiza solo (una vez al día como mucho), comprobando + antes que la versión nueva funciona; si no, sigue con la anterior. +- Las versiones nuevas de PS5 Directo se avisan solas con una ventana y sus + novedades. +- Avisa si la velocidad hacia la PS5 cae de golpe (suele ser la consola + inactiva: pulsa un botón del mando). +- «Formato en la PS5» empieza plegado y dice cuánto tarda: sin convertir, + descarga y envío a la vez; convirtiendo, 3 pasos. Tarjetas ordenadas por lo + más usado y «¿Cuál elijo?» por necesidades. +- Firmware de tu PS5 junto al título (se guarda solo) y la tarjeta .fpkg dice + si te vale. +- En la cola, «Convertir a …» dice a qué formato, y avisa si algo no se pudo + convertir. +- Arreglado: un comprimido con versión normal y backport no se convertía; + ahora separa la que toca. +- Arreglado: lo convertido se mezclaba con una carpeta anterior del mismo + nombre. +- Arreglado: muchas carpetas de la Biblioteca salían sin nombre. + +NOVEDADES 1.20.1 +- Si no conecta y el PC y la PS5 están en redes distintas (por ejemplo + 192.168.50.x y 192.168.1.x), lo dice y explica cómo arreglarlo. +- Botones «Usar en el móvil» y «Extensión del navegador» más visibles, y el + asistente de inicio los ofrece al final. +- «Descargar de nuevo» en la cola (terminados, con error o cancelados) y en + Actividad: vuelve a empezar desde cero sin tener que pegar el enlace. +- Si al colocar ya existe esa carpeta, pregunta (colocar igualmente con otro + nombre, reemplazar o cancelar) en vez de dar error. +- Arreglado: si el comprimido trae la versión normal y un «backport», ya no + da «Ya existe»: coloca la normal y aparta el backport (o lo descarta si tu + firmware no lo necesita). Y si en el destino hay un envío anterior a + medias, lo sustituye. +- «Guía de uso» renovada: explica el convertidor de formatos, cada pestaña, + el uso desde el móvil y la extensión del navegador. +- La ventana «¿Cuál elijo?» añade velocidades estimadas donde no había datos + y textos más claros sobre LZ4 y .ffpfsc. + NOVEDADES 1.20.0 - Asistente de primeros pasos (IP, comprobar la conexión y cargar lo que falta, firmware) y una guía más corta y sencilla. @@ -438,7 +506,7 @@ NOVEDADES 1.14.1 NOVEDADES 1.14 - Guía de bienvenida la primera vez que se abre: enseña para qué es cada - parte. Se puede volver a ver con el botón "Guía" de arriba. + parte. Se puede volver a ver con el botón "Guía de uso" de arriba. NOVEDADES 1.13.1 - El logo de la cabecera va dentro de la página (al actualizar desde una diff --git a/README.md b/README.md index 54a8100..d473769 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ **English** · [Español](#español) -A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the console over FTP **while they download**, without storing them on your PC. It then extracts them, either on the console with BFpilot or on the PC with 7-Zip, and places apps in `/data/homebrew` so ShadowMountPlus shows them in the menu. +An all-in-one Windows tool for jailbroken PS5 consoles: it **downloads, converts and sends** in a single step. Sent as is, files go to the console over FTP **while they download**, without storing them on your PC; or it **converts them on the fly** (LZ4, `.exfat`, folder, `.ffpfsc`…) as part of the same job, no second program needed. It extracts on the console (BFpilot) or on the PC (7-Zip), places apps in `/data/homebrew` so ShadowMountPlus shows them in the menu, and lets you browse your console's library with covers and control everything from your phone. -![PS5 Directo: paste a link, it's sent, extracted and shows up in the library](docs/demo_en.gif) +![PS5 Directo: paste a link, send it as is or convert it, and it shows up in the library](docs/demo_en.gif) Demo with a sample homebrew app served from a NAS on the local network. - ✅ Streams downloads straight to the console: nothing is saved on the PC -- ✅ Works from download links, ftp:// links (e.g. a NAS) or files on your PC +- ✅ Works from download links, ftp:// links (e.g. a NAS), torrents (magnet or .torrent) or files on your PC - ✅ Joins split parts (.part1, .part2… / .001, .002…) and extracts RAR, 7z, ZIP and tar (.gz/.bz2/.xz) - ✅ 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) @@ -24,8 +24,13 @@ A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the - ✅ Optional browser extension: links arrive by themselves with your browser session (same speed and access as the browser) - ✅ Drag and drop, or "Send to" from Explorer - ✅ Welcome guide, one-click updates, English and Spanish -- ✅ **Library tab**: covers, versions and the firmware each app needs, flagging what won't launch on your console (backport-aware), duplicates and incomplete folders -- ✅ **Images and LZ4** with [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge) (optional): send an app as a single `.ffpkg` / `.ffpfsc` image or LZ4-packed, prepared on the PC +- ✅ **Library tab**: covers, versions and the firmware each app needs, flagging what won't launch on your console (backport-aware), duplicates and incomplete folders. Also shows what's installed (PS4 and PS5), your PC/NAS collection with what's missing on the console, and links to each title's public update list +- ✅ **Backup tab**: saved data, ShadowMountPlus/payload settings and the app database copied to the PC, manually or daily; restore saved data to the same console +- ✅ **Captures tab**: view the console's screenshots and videos and download them +- ✅ **Diagnosis** on failed transfers (cause, codes and fixes) and a one-click problem report (.zip, no passwords) +- ✅ **Format converter** with [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge) (optional): send it as is, or turn it on the PC into a plain folder, `.exfat`, LZ4, `.ffpfsc`, `.ffpkg`, `.ffpfs` or `.fpkg`, with a "Which one?" guide by need. Archives with a normal version plus a backport are split automatically +- ✅ Shows your PS5 firmware at the top and tells you which formats and apps will work on it +- ✅ "Download again" from the queue or the history, without pasting the link - ✅ Asks before sending something that's already on the console (send anyway, replace or cancel) - ✅ Checks every part against its `.sfv` / `.md5` / `.sha1` while sending - ✅ "Make room": suggests what to delete when space runs out @@ -55,7 +60,7 @@ The full guide is in [README_EN.txt](README_EN.txt) (Spanish: [LEEME.txt](LEEME. ## Publishing a release -Bump `VERSION` in `app.py`, add the notes to `LEEME.txt` / `README_EN.txt`, then push a tag (`git tag v1.20.0 && git push --tags`). The GitHub Action builds both zips (plain and portable with Python) and creates the release. +Bump `VERSION` in `app.py`, add the notes to `LEEME.txt` / `README_EN.txt`, then push a tag (`git tag v1.20.3 && git push --tags`). The GitHub Action builds both zips (plain and portable with Python) and creates the release. ## Disclaimer @@ -70,11 +75,13 @@ Independent project, not affiliated with Sony or with the authors of the tools m ## Español -![PS5 Directo: pegas un enlace, se envía, se descomprime y aparece en la biblioteca](docs/demo_es.gif) +![PS5 Directo: pegas un enlace, lo envías tal cual o lo conviertes, y aparece en la biblioteca](docs/demo_es.gif) -Herramienta de transferencia de archivos para Windows: envía archivos a una PS5 con jailbreak por FTP **mientras se descargan**, sin pasar por el disco del PC. Después los descomprime (en la consola con BFpilot, o en el PC con 7-Zip) y coloca las aplicaciones en `/data/homebrew` para que ShadowMountPlus las muestre en el menú. +Herramienta todo en uno para Windows y PS5 con jailbreak: **descarga, convierte y envía** en un solo paso. Tal cual, los archivos van a la consola por FTP **mientras se descargan**, sin pasar por el disco del PC; o los **convierte sobre la marcha** (LZ4, `.exfat`, carpeta, `.ffpfsc`…) dentro del mismo trabajo, sin usar otro programa. Descomprime en la consola (BFpilot) o en el PC (7-Zip), coloca las aplicaciones en `/data/homebrew` para que ShadowMountPlus las muestre en el menú, y te deja ver la biblioteca de la consola con portadas y controlarlo todo desde el móvil. -**Novedades 1.20:** pestaña Biblioteca con portadas y aviso de firmware, imágenes `.ffpkg`/`.ffpfsc` y LZ4 con [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge), pregunta si ya está en la consola, comprobación con `.sfv`/`.md5`, "Hacer sitio", cargar ftpsrv/BFpilot desde el programa, diagnóstico de red, acceso desde el móvil con QR y programar la cola. +**Novedades 1.20.3:** ventana de bienvenida, Biblioteca con lo instalado (PS4 y PS5) y con tu colección del PC/NAS, copias de seguridad de partidas y ajustes, capturas y vídeos, diagnóstico de errores con informe, torrents y avisos de actualización automáticos. + +**Novedades 1.20:** pestaña Biblioteca con portadas y aviso de firmware, convertidor de formatos con [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge) (carpeta, `.exfat`, LZ4, `.ffpfsc`, `.ffpkg`, `.ffpfs` y `.fpkg`), firmware de tu PS5 siempre a la vista, «Descargar de nuevo», pregunta si ya está en la consola, comprobación con `.sfv`/`.md5`, "Hacer sitio", cargar ftpsrv/BFpilot desde el programa, diagnóstico de red, acceso desde el móvil con QR y programar la cola. **Requisitos:** Windows 10/11. Descarga el `-portable-windows.zip` (trae Python, no hay que instalar nada) o el `.zip` normal con [Python 3.9+](https://www.python.org/downloads/). Para descomprimir en el PC, [7-Zip](https://www.7-zip.org/). En la PS5: servidor FTP ([ftpsrv](https://github.com/ps5-payload-dev/ftpsrv)), [BFpilot](https://github.com/ItsBlurf/BFpilot) y [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus). diff --git a/README_EN.txt b/README_EN.txt index ac71dae..b75fae2 100644 --- a/README_EN.txt +++ b/README_EN.txt @@ -152,7 +152,7 @@ HOW TO USE IT ------------- 1. Extract this folder anywhere you like. 2. Open PS5 Directo from the desktop icon. A short welcome guide explains - each part (you can see it again with the "Guide" button). + each part (you can see it again with the "User guide" button). 3. The first time, Settings opens: enter your PS5's IP (on the PS5: Settings > Network > View Connection Status), click "Save" and then "Test connection". Both dots at the top should turn green. @@ -313,20 +313,81 @@ PS5Directo-X.Y.Z-portable-windows.zip, which includes Python: extract and run. WHAT'S NEW ---------- +1.20.3 - Library: "Check" on each app, for when it won't open or closes on + launch. It checks the folder (executable, param.json, permissions, + firmware, backports and LZ4 packing), shows logs (.log) and captures + from the last minutes, explains the error code shown on the TV and + saves a .zip report. Read-only: it changes nothing. + - Welcome window on first launch: sums up what PS5 Directo is and does. + - Library: also what's installed on the console (PS4 and PS5), with + cover, version and firmware, and a filter for it. + - Library, "On your PC": pick PC or NAS folders, see what's already on + the console and what isn't, and send it with one click. + - Library: "See updates" opens each title's public official update list + in your browser (Prospero Patches). Warns that updates needing + firmware 14.00 or higher can't be installed. The program downloads + nothing. + - Library: if PS5 Game Updater is on the console, a button opens it; if + its folder is misplaced, it detects and fixes it. + - Library: images and folders without param.json show name, cover and + firmware. + - New "Backup" tab: saves saved data, ShadowMountPlus and payload + settings and the app database to the PC, manually or daily. Saved data + can be restored to the same console (current data is backed up first). + - New "Captures" tab: the console's screenshots and videos with + thumbnails, to view and download to the PC. + - "Diagnosis" on failed transfers: what happened, codes and how to fix + it, plus a .zip report (no passwords) to send to whoever helps you. In + Settings, "Create problem report". + - Torrents: paste a magnet or a .torrent. It's downloaded to the PC + first with aria2 (fetched automatically) and then sent, extracted or + converted. No seeding afterwards; deleted from the PC. + - PS5 Dump Forge updates itself (at most daily), checking first the new + version works; otherwise it keeps the previous one. + - New PS5 Directo versions are announced by themselves in a window with + what's new. + - Warns if the speed to the PS5 suddenly drops (usually the console + idling: press a controller button). + - "Format on the PS5" starts folded and says how long it takes: no + conversion downloads and sends at once; converting takes 3 steps. + Cards sorted by most used and "Which one?" by need. + - Your PS5 firmware next to the title (saves automatically) and the + .fpkg card tells you if it works. + - In the queue, "Convert to …" names the format, and it warns if + something couldn't be converted. + - Fixed: an archive with a normal version plus a backport wasn't + converted; now it picks the right one. + - Fixed: converted files got mixed into an older folder with the same + name. + - Fixed: many Library folders showed no name. +1.20.1 - If it can't connect and the PC and PS5 are on different networks, + it says so and explains how to fix it. + - Clearer "Use on your phone" and "Browser extension" buttons, also + offered at the end of the first-steps wizard. + - "Download again" in the queue and in Activity: starts over without + pasting the link again. If the target already exists when placing, + it asks (keep both, replace or cancel) instead of failing. + - Fixed: archives with the normal app plus a "backport" folder no + longer fail with "Already exists"; half-sent leftovers are + replaced. + - Updated "User guide": explains the format converter, every tab, + phone access and the browser extension. + - The "Which one?" window adds estimated speeds where there was no + data, with clearer notes on LZ4 and .ffpfsc. 1.20.0 - First-steps wizard (IP, connection check and loading what's missing, firmware) and a shorter, simpler guide. - New Library tab: what's on the console with cover, version, format and required firmware, flagging what won't launch on yours. - .ffpkg / .ffpfsc images and LZ4 with PS5 Dump Forge (optional): - prepared on the PC and sent as a single file. Picked with cards under - the destination, each explained, with the room needed on the PC; the - first time it warns that converting takes PC space until it's done. - A download that's already an image is sent as is or converted to - the chosen format, and placed even without BFpilot. + prepared on the PC and sent as a single file. Picked with cards + under the destination, each explained, with the room needed on the + PC; the first time it warns that converting takes PC space until + it's done. A download that's already an image is sent as is or + converted to the chosen format, and placed even without BFpilot. - New formats: plain folder, .exfat, .ffpfs and .fpkg (with a compression choice). Without 7-Zip, PS5 Dump Forge extracts it. - - Formats are grouped (space savers first) and "Which one?" - compares the pros and cons of each. + - Formats are grouped (space savers first) and "Which one?" compares + the pros and cons of each. - Time left (and rough finish time) when sending, extracting and converting. On the phone, a big alert with vibration and a beep when something finishes or fails (with the page open). @@ -346,8 +407,9 @@ WHAT'S NEW - Schedule the queue, sleep or shut down when done, no sleep while sending. - Portable version with Python included. - - Fixed: with FTP only (no BFpilot), ftpsrv returned the console's root - when listing a folder; it affected the file browser and folder uploads. + - Fixed: with FTP only (no BFpilot), ftpsrv returned the console's + root when listing a folder; it affected the file browser and + folder uploads. - Phone access: a button to allow it in Windows Firewall if the page stays blank. 1.19.2 - Fixed "504 Option not supported" in Console files when BFpilot is diff --git a/__pycache__/engine.cpython-315.pyc b/__pycache__/engine.cpython-315.pyc deleted file mode 100644 index df4546e..0000000 Binary files a/__pycache__/engine.cpython-315.pyc and /dev/null differ diff --git a/app.py b/app.py index 32010bb..df85230 100644 --- a/app.py +++ b/app.py @@ -24,7 +24,7 @@ sys.path.insert(0, HERE) import engine as E # noqa: E402 import features as F # noqa: E402 -VERSION = "1.20.0" +VERSION = "1.20.3" # Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas, # todas las copias buscan actualizaciones ahi sin tener que configurar nada. DEFAULT_REPO = "jstrdev/ps5directo" @@ -55,6 +55,7 @@ DEFAULTS = { "passwords": [], "guess_passwords": True, "delete_archives": True, + "forge_auto_update": True, # bajar solo las versiones nuevas de PS5 Dump Forge (comprobando que valen) "tour_done": False, # la guia de bienvenida ya se vio "seen_version": "", # ultima version cuyas novedades ya se ensenaron "shortcut_done": False, # ya se creo el acceso directo del escritorio una vez @@ -67,6 +68,12 @@ DEFAULTS = { "pc_format": "folder", # en el PC: folder, ffpkg, ffpfsc, lz4, lz4ffpkg (con PS5 Dump Forge) "forge_path": "", # ruta de ps5-dump-forge.exe (vacio = descargarlo solo) "keep_awake": True, # que el PC no se suspenda mientras hay envios + "welcome_seen": False, # la ventana de bienvenida ya se ha visto + "pc_collection": [], # carpetas del PC o del NAS con contenido para la consola (pestana Biblioteca > Tu PC) + "backup_dir": "", # copias de seguridad de la consola (vacio = Documentos\\PS5 Directo\\Copias) + "backup_auto": False, # una copia al dia, sola, si la consola esta encendida + "backup_keep": 10, # cuantas copias guardar + "backup_saves": True, "backup_config": True, "backup_appdb": True, "lan_access": False, # abrir la app desde el movil (misma red) "lan_port": 8780, "lan_token": "", @@ -709,8 +716,14 @@ def record_history(job): "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 "", + # para "Descargar de nuevo" desde el historial + "redo": {"lines": list(job.get("orig_lines") or job.get("lines") or []), "where": job.get("where") or "", + "raw": bool(job.get("raw")), "fmt": job.get("fmt") or "", "comp": job.get("comp") or "", + "fmt_set": bool(job.get("fmt_set"))}, }) _save_json(HISTORY_FILE, hist[-500:]) + if job.get("status") == "hecho": + torrent_cleanup(job) title = job.get("title") or "Transferencia" link = {"tab": "Transfers", "job": job["id"]} if job.get("status") == "hecho": @@ -1509,6 +1522,112 @@ def fm_api(p, b): else: E.Ftp(cfg_snapshot()).call(lambda f: f.mkd(path)) return {} + if p == "/api/report/diag": + j = find(str(b.get("id") or "")) + if not j: + raise E.PipeError("No encuentro esa transferencia") + d = diagnose(j) + d.update(error=j.get("error") or "", phase=j.get("phase") or "", log=list(j.get("log") or [])[-40:], + title=_job_name(j)) + return d + if p == "/api/report/make": + fp = make_report(str(b.get("id") or "") or None) + return {"path": fp, "name": os.path.basename(fp)} + if p == "/api/report/open": + os.makedirs(report_dir(), exist_ok=True) + if os.name == "nt": + os.startfile(report_dir()) + return {"dir": report_dir()} + if p == "/api/lib/check": + return launch_check(str(b.get("path") or ""), int(b.get("minutes") or 30)) + if p == "/api/lib/checkreport": + import zipfile + r = launch_check(str(b.get("path") or ""), int(b.get("minutes") or 30)) + os.makedirs(report_dir(), exist_ok=True) + tag = re.sub(r"[^\w-]+", "_", r.get("title") or r.get("tid") or "aplicacion")[:40] + fp = os.path.join(report_dir(), "arranque-%s-%s.zip" % (tag, time.strftime("%Y-%m-%d_%H%M"))) + with zipfile.ZipFile(fp, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("comprobacion.json", json.dumps({k: v for k, v in r.items() if k != "codes"}, + ensure_ascii=False, indent=1)) + z.writestr("codigo_de_error.txt", str(b.get("code") or "")) + z.writestr("ajustes_sin_contrasenas.json", json.dumps(_clean_settings(), ensure_ascii=False, indent=1)) + for k, c in enumerate(r.get("captures") or []): + try: + cp, _ = media_cached(c["path"]) + z.write(cp, "capturas/%d_%s" % (k, c["name"])) + except Exception: + pass + return {"path": fp, "name": os.path.basename(fp)} + if p == "/api/col/state": + with LOCK: + return {"col": dict(COL), "roots": list(SETTINGS.get("pc_collection") or [])} + if p == "/api/col/scan": + if not COL["busy"]: + threading.Thread(target=collection_scan, daemon=True).start() + return {} + if p == "/api/col/set": + roots = [str(x).strip().strip('"') for x in (b.get("roots") or []) if str(x).strip()] + with LOCK: + SETTINGS["pc_collection"] = roots + save_settings() + return {"roots": roots} + if p == "/api/media/list": + return {"items": media_list()} + if p == "/api/media/clearcache": + import shutil + shutil.rmtree(MEDIA_DIR, ignore_errors=True) + return {} + if p == "/api/bk/state": + with LOCK: + st = dict(BK) + st["dir"] = backup_dir() + st["last_t"] = SETTINGS.get("backup_last") or 0 + return {"bk": st} + if p == "/api/bk/list": + return {"items": backup_list(), "dir": backup_dir()} + if p == "/api/bk/run": + if BK["busy"]: + raise E.PipeError("Ya hay una copia en marcha") + secs = [x for x in (b.get("sections") or []) if x in ("saves", "config", "appdb", "list")] or None + threading.Thread(target=backup_run, args=(secs,), daemon=True).start() + return {} + if p == "/api/bk/cancel": + BK_CANCEL.set() + return {} + if p == "/api/bk/restore": + if BK["busy"]: + raise E.PipeError("Espera a que termine la copia en marcha") + threading.Thread(target=backup_restore, args=(str(b.get("path") or ""), b.get("ids") or []), + daemon=True).start() + return {} + if p == "/api/bk/open": + d = backup_dir() + os.makedirs(d, exist_ok=True) + if os.name == "nt": + os.startfile(d) + return {"dir": d} + if p == "/api/gu/status": + return gu_status() + if p == "/api/gu/fix": + st = gu_status() + src = st.get("fix_from") + if not src: + raise E.PipeError("No hay nada que arreglar") + dst = "/data/homebrew/" + GU_TID + if st.get("present"): + raise E.PipeError("Ya existe %s" % dst) + bf = E.BFpilot(cfg_snapshot()) + if bf.alive(): + bf.rename(src, dst) + else: + E.Ftp(cfg_snapshot()).call(lambda f: f.rename(src, dst)) + parent = posixpath.dirname(src) + if parent != "/data/homebrew": + try: + E.Ftp(cfg_snapshot()).call(lambda f: f.rmd(parent)) # solo si se ha quedado vacia + except ftplib.all_errors: + pass + return {"path": dst} if p == "/api/fm/rename": src, dst = fm_clean(b.get("src")), fm_clean(b.get("dst")) if posixpath.basename(dst) in _names_in(posixpath.dirname(dst) or "/"): @@ -1549,6 +1668,19 @@ def fm_api(p, b): return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")} if p == "/api/history": return {"items": read_history()} + if p == "/api/history/redo": + h = next((x for x in read_history() if x.get("id") == b.get("id")), None) + r = (h or {}).get("redo") + if not r or not r.get("lines"): + raise E.PipeError("No sé qué enlaces tenía esa transferencia (es de una versión anterior)") + nj = new_job(r["lines"], "", h.get("dest") or SETTINGS["dest"], title=h.get("title"), where=r.get("where"), + raw=r.get("raw"), fmt=r.get("fmt"), comp=r.get("comp")) + nj["fmt_set"] = r.get("fmt_set", False) + with WAKE: + JOBS.append(nj) + WAKE.notify_all() + save_jobs() + return {"id": nj["id"]} if p == "/api/notifs": with NOTIF_LOCK: return {"items": list(NOTIFS), "unread": sum(1 for n in NOTIFS if not n.get("read"))} @@ -1713,6 +1845,473 @@ FORGE_LOCK = threading.Lock() LIB_FILE = os.path.join(DATA_DIR, "library.json") LIB_LOCK = threading.Lock() COVER_DIR = os.path.join(DATA_DIR, "covers") +MEDIA_DIR = os.path.join(DATA_DIR, "media") +MEDIA_ROOT = "/user/av_contents" +MEDIA_TYPES = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", + ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime"} + + +# --------------------------------------------------------------------------- +# Diagnostico e informe de fallo +# --------------------------------------------------------------------------- + +DIAG_RULES = [ + (r"(?i)10061|refused|rechaz|No route|timed out|unreachable|No puedo conectar con el FTP|conexi[oó]n con la PS5", + "No se pudo hablar con la consola por FTP.", + ["Comprueba que la PS5 está encendida y que el servidor FTP (ftpsrv) está cargado: el punto de FTP arriba tiene que " + "estar en verde.", "Revisa la IP de la PS5 en Ajustes.", + "Si el PC y la PS5 están en redes distintas (por ejemplo 192.168.50.x y 192.168.1.x), conéctalos al mismo router.", + "En Ajustes, «Revisión completa» te dice qué falla."]), + (r"(?i)BFpilot", "BFpilot no responde o no está cargado.", + ["Carga BFpilot en la consola (pulsa el punto de BFpilot arriba) y pulsa Reintentar.", + "Si no puedes usar BFpilot, elige «En el PC (7-Zip)» en «Dónde descomprimir»."]), + (r"(?i)No (cabe|hay sitio) en la PS5|Parado para no llenar|ESPACIO", "No hay espacio suficiente en la consola.", + ["Libera espacio (pestaña Espacio → «Hacer sitio») y pulsa Reintentar: sigue donde iba.", + "O baja el margen que se deja libre en Ajustes."]), + (r"(?i)No cabe en el PC|No space left|Errno 28", "No hay espacio suficiente en el disco del PC.", + ["Libera espacio en el PC o cambia la carpeta temporal en Ajustes a otro disco.", + "Sin convertir y descomprimiendo en la PS5 no hace falta sitio en el PC."]), + (r"(?i)contrase|password|encrypt", "El comprimido tiene contraseña y no se ha encontrado la buena.", + ["Escribe la contraseña en la transferencia (o en Ajustes → contraseñas) y pulsa Reintentar."]), + (r"(?i)CRC|da[nñ]ad|corrupt|checksum|sumas", "Algún archivo ha llegado dañado o no cuadra con su comprobación.", + ["Pulsa «Descargar de nuevo»: si vuelve a pasar, el archivo de origen está mal.", + "Si tiene contraseña, comprueba que es la correcta (una contraseña mala también da error de CRC)."]), + (r"(?i)HTTP (403|404|410)|caduc|expired|Forbidden|Not Found", "El enlace de descarga ya no vale (caducado o borrado).", + ["Saca un enlace nuevo en la web de descarga y pégalo otra vez."]), + (r"(?i)Name or service not known|getaddrinfo|nodename nor servname|No se puede abrir el enlace|11001", + "El PC no ha podido llegar al servidor del enlace.", + ["Comprueba que el PC tiene internet.", "Revisa que el enlace está bien copiado, entero y sin espacios.", + "Si es de tu NAS, comprueba que está encendido y que el nombre o la IP son correctos."]), + (r"(?i)quedo corta|se cort|Conexion cortada|connection reset|reset by peer|incompleta", "La descarga o el envío se cortó a medias.", + ["Pulsa Reintentar: sigue donde iba, no empieza de cero.", + "Si pasa a menudo, prueba con cable de red o baja «Conexiones por archivo» en Ajustes."]), + (r"(?i)HTTP 429|limita las conexiones|Too Many", "El servidor de descarga está limitando las conexiones.", + ["Espera unos minutos y pulsa Reintentar, o baja «Conexiones por archivo» en Ajustes."]), + (r"(?i)CE-107750|permis", "Problema de permisos: la consola no deja abrir los archivos.", + ["En la Biblioteca pulsa «Permisos» en esa aplicación (pone 777)."]), + (r"(?i)pide (un )?firmware m[aá]s|no arrancar|solo funcionan con firmware|no te funcionar", "Pide un firmware más nuevo que el de tu PS5.", + ["Busca una versión con backport para tu firmware o una versión anterior."]), + (r"(?i)Dump Forge|Forge:|convert", "Ha fallado la conversión de formato en el PC.", + ["Prueba «Sin convertir» o «Carpeta»: el contenido se envía igual.", + "Si quieres convertir, prueba otro formato (.exfat suele ser el más compatible)."]), + (r"(?i)AMPR|LZ4", "LZ4 no vale para este contenido.", + ["Elige «Sin convertir», «Carpeta» o «Imagen .exfat»."]), + (r"(?i)torrent|aria2", "No se pudo bajar el torrent.", + ["Comprueba que el torrent tiene gente compartiendo (pares) y que el PC tiene internet.", + "Si el antivirus o el firewall bloquean aria2, dale permiso."]), + (r"(?i)Ya existe", "En la consola ya hay algo con ese nombre.", + ["Elige en la cola si lo colocas igualmente con otro nombre, reemplazas lo que hay o cancelas."]), +] + + +def diagnose(job): + bad = [x for x in list(job.get("log") or [])[-60:] + if re.search(r"(?i)error|fallo|no pude|no se pudo|no puedo|aviso|warning|ojo|no cabe|no hay sitio|aria2:|HTTP \d{3}|corta", x)] + text = " ".join([job.get("error") or "", job.get("phase") or ""] + bad) + first = job.get("error") or "" + found, seen = [], set() + # primero lo que explica el error final; luego lo que sale en el registro + for src in (first, text): + for rx, cause, fixes in DIAG_RULES: + if cause in seen or not re.search(rx, src): + continue + seen.add(cause) + found.append({"cause": cause, "fixes": fixes}) + if not found: + found.append({"cause": "No reconozco este error.", + "fixes": ["Pulsa Reintentar: muchos fallos de red se arreglan solos.", + "Si sigue fallando, guarda el informe y pásaselo a quien te ayude."]}) + codes = sorted(set(re.findall(r"\b(CE-\d{6}-\d|HTTP \d{3}|Errno \d+|WinError \d+|errorCode=\d+|codigo -?\d+|código -?\d+|0x[0-9a-fA-F]{6,8})\b", + text))) + return {"items": found[:4], "codes": codes} + + +SECRET_KEYS = ("ftp_pass", "passwords", "lan_token", "token", "password") + + +def _clean_settings(): + with LOCK: + st = dict(SETTINGS) + for k in list(st): + if any(x in k.lower() for x in SECRET_KEYS): + st[k] = "(oculto)" if st[k] else "" + return st + + +def _clean_job(j): + d = {k: v for k, v in j.items() if k not in ("password", "info")} + d["has_password"] = bool(j.get("password")) + return d + + +def _job_name(j): + if j.get("title"): + return j["title"] + src = (j.get("sources") or [{}])[0].get("name") or ((j.get("lines") or [""])[0].replace("\\", "/").rsplit("/", 1)[-1]) + return src or "transferencia" + + +def report_dir(): + return os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo", "Informes") + + +def make_report(job_id=None): + """Un .zip para pasar a quien te ayuda: registro, ajustes (sin contraseñas), versiones y estado.""" + import zipfile + import platform + with LOCK: + jobs = [dict(j) for j in JOBS if job_id is None or j["id"] == job_id] + if job_id and not jobs: + raise E.PipeError("No encuentro esa transferencia") + os.makedirs(report_dir(), exist_ok=True) + tag = re.sub(r"[^\w-]+", "_", _job_name(jobs[0]) if job_id else "general")[:40] + fp = os.path.join(report_dir(), "informe-%s-%s.zip" % (tag, time.strftime("%Y-%m-%d_%H%M"))) + forge = F.find_forge(SETTINGS.get("forge_path"), DATA_DIR) + info = {"app": "PS5 Directo", "version": VERSION, "date": time.strftime("%Y-%m-%d %H:%M:%S"), + "os": platform.platform(), "python": sys.version.split()[0], + "forge": os.path.basename(os.path.dirname(forge)) if forge else None, + "console_fw": SETTINGS.get("console_fw")} + try: + info["connection"] = test_connection(cfg_snapshot()) + except Exception as e: + info["connection"] = str(e) + try: + info["network"] = net_mismatch(cfg_snapshot()) or "PC y PS5 en la misma red" + except Exception: + pass + summary = [] + with zipfile.ZipFile(fp, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("version_y_conexion.json", json.dumps(info, ensure_ascii=False, indent=1)) + z.writestr("ajustes_sin_contrasenas.json", json.dumps(_clean_settings(), ensure_ascii=False, indent=1)) + for j in jobs: + name = re.sub(r"[^\w-]+", "_", (j.get("title") or j["id"]))[:40] + "_" + j["id"] + z.writestr("transferencias/%s.json" % name, json.dumps(_clean_job(j), ensure_ascii=False, indent=1)) + z.writestr("transferencias/%s.txt" % name, "\n".join(j.get("log") or [])) + dg = diagnose(j) if j.get("status") in ("error", "pausado", "cancelado") else None + summary.append("%s [%s]%s" % (j.get("title") or j["id"], j.get("status"), + (": " + (j.get("error") or "")) if j.get("error") else "")) + if dg: + for it in dg["items"]: + summary.append(" - " + it["cause"]) + if job_id is None: + z.writestr("actividad.json", json.dumps(read_history()[-60:], ensure_ascii=False, indent=1)) + with NOTIF_LOCK: + z.writestr("avisos.json", json.dumps(NOTIFS[:60], ensure_ascii=False, indent=1)) + z.writestr("LEEME.txt", "Informe de PS5 Directo %s (%s)\n\n%s\n\nNo lleva contraseñas. Pásaselo a quien te " + "ayude.\n" % (VERSION, info["date"], "\n".join(summary))) + return fp + + +# --------------------------------------------------------------------------- +# Comprobar arranque: por que una aplicacion de la Biblioteca no abre (solo lectura) +# --------------------------------------------------------------------------- + +LAUNCH_CODES = { + "CE-107750-0": "La consola no puede abrir los archivos: suele ser un problema de permisos. Pulsa «Permisos» " + "en la tarjeta (pone 777) y prueba otra vez.", + "CE-108255-1": "La aplicación se ha cerrado por un error al arrancar. Si es LZ4, prueba la versión sin convertir " + "o .exfat; si no, mira el firmware que pide y el backport.", + "CE-100005-6": "La consola no puede leer los datos: pueden estar incompletos o dañados. Vuelve a enviarla.", + "CE-100002-3": "Pide una versión del sistema más nueva. Busca una versión con backport para tu firmware.", + "CE-34878-0": "La aplicación se ha cerrado por un error. Prueba a reiniciar la consola y vuelve a abrirla; si " + "sigue, vuelve a enviarla o prueba otro formato.", + "CE-30005-8": "Error al leer los datos de la aplicación. Vuelve a enviarla.", +} +LOG_RX = re.compile(r"(?i)\.(log|txt)$") + + +def _console_now(f): + """La hora segun la consola (su FTP puede ir con otra zona horaria que el PC): crea un archivo vacio en la + carpeta de trabajo, mira la fecha que le pone y lo borra.""" + import io + p = WORK_ROOT + "/.reloj" + try: + E.Ftp.mkdirs(f, WORK_ROOT) + f.storbinary("STOR " + p, io.BytesIO(b"")) + mt = dict(E.ftp_listdir(f, WORK_ROOT)).get(".reloj", {}).get("mtime") + try: + f.delete(p) + except ftplib.all_errors: + pass + return mt or time.time() + except ftplib.all_errors: + return time.time() + + +def _recent_logs(f, roots, since, limit=6): + """Archivos .log/.txt modificados despues de 'since' (hasta 3 niveles), con sus ultimas lineas.""" + found = [] + + def walk(path, depth): + try: + ls = E.ftp_listdir(f, path) + except ftplib.all_errors: + return + for n, v in ls: + p = path.rstrip("/") + "/" + n + if v["type"] == "dir": + if depth < 3 and n not in ("homebrew", "pkg", "ps5directo") and not n.startswith("."): + walk(p, depth + 1) + elif LOG_RX.search(n) and (v.get("mtime") or 0) >= since and (v.get("size") or 0) < 64 * 1024 * 1024: + found.append((v.get("mtime") or 0, p, v.get("size") or 0)) + for r in roots: + walk(r, 0) + found.sort(reverse=True) + out = [] + for mt, p, sz in found[:limit]: + try: + raw = F.ftp_read(f, p, max(0, sz - 6000), 6000).decode("utf-8", "replace") + except Exception: + raw = "" + out.append({"path": p, "mtime": mt, "tail": raw[-6000:]}) + return out + + +def launch_check(path, minutes=30): + cfg = cfg_snapshot() + path = fm_clean(path) + checks = [] + + def add(level, text, fix=""): + checks.append({"level": level, "text": text, "fix": fix}) + info = {} + + def run(f): + is_dir = True + try: + ls = {n: v for n, v in E.ftp_listdir(f, path)} + except ftplib.all_errors: + is_dir = False + ls = {} + if not is_dir: + add("info", "Es una imagen (%s): lo de dentro solo se puede revisar cuando ShadowMountPlus la monta." + % posixpath.basename(path), "Si no aparece en el menú, prueba a enviarla como carpeta o .exfat.") + else: + sce = {} + if "sce_sys" in ls: + sce = {n: v for n, v in E.ftp_listdir(f, path + "/sce_sys")} + if "eboot.bin" in ls: + add("ok", "Tiene eboot.bin (el ejecutable principal).") + mode = ls["eboot.bin"].get("mode") + if mode is not None and (mode & 0o111) == 0: + add("bad", "eboot.bin no tiene permiso de ejecución.", "Pulsa «Permisos» en la tarjeta (pone 777).") + else: + add("bad", "Falta eboot.bin: la aplicación no puede arrancar.", "Vuelve a enviarla: está incompleta.") + if "param.json" in sce or "param.sfo" in sce: + add("ok", "Tiene sce_sys/param.json.") + else: + add("bad", "Falta sce_sys/param.json: la consola no sabe qué es.", "Vuelve a enviarla: está incompleta.") + if "icon0.png" not in sce: + add("warn", "Falta la portada (icon0.png). No impide arrancar.") + facts = {} + if "param.json" in sce: + try: + facts = F.param_facts(F.ftp_read(f, path + "/sce_sys/param.json", 0, 512 * 1024) + .decode("utf-8-sig", "replace")) + except Exception: + pass + info.update(tid=facts.get("tid") or E.title_id(posixpath.basename(path)), title=facts.get("title")) + fw_exe = None + if "eboot.bin" in ls: + try: + fw_exe = F.exe_firmware(lambda off, n: F.ftp_read(f, path + "/eboot.bin", off, n)) + except Exception: + pass + backport = "fakelib" in ls or "fakelib2" in ls + need = (fw_exe if backport else None) or facts.get("fw_param") or fw_exe + mine = (SETTINGS.get("console_fw") or "").strip() + if need and mine: + ok = F.fw_ok(mine, need) + if ok is False: + add("bad", "Pide firmware %s y tu PS5 tiene %s." % (need, mine), + "Necesitas una versión con backport para tu firmware.") + elif ok: + add("ok", "Firmware: pide %s y tu PS5 tiene %s." % (need, mine)) + elif need: + add("info", "Pide firmware %s. Pon el de tu PS5 en Ajustes para comprobarlo." % need) + if backport: + add("info", "Lleva backport (carpeta fakelib).") + # LZ4 / AMPR + amp = [n for n in ls if n.lower().startswith("ampr_assets")] + if amp: + vols = [n for n in amp if n.lower() != "ampr_assets.index" and not n.lower().endswith((".crc", ".runtime"))] + if "ampr_assets.index" not in [n.lower() for n in amp]: + add("bad", "Está empaquetada en LZ4 pero falta ampr_assets.index.", "Vuelve a convertirla o envíala sin convertir.") + else: + add("ok", "LZ4: tiene el índice de paquetes (%d archivos de datos)." % len(vols)) + if not any(n.lower() == "ampr_emu.index" for n in ls): + add("warn", "LZ4: falta ampr_emu.index.", "Si no arranca, vuelve a convertirla o envíala sin convertir.") + if not any(n.lower().endswith(".runtime") for n in amp): + add("info", "LZ4: no lleva perfil .runtime (usa los valores por defecto). Si se cierra al arrancar, " + "es una causa habitual.", "Prueba a enviarla sin convertir, como carpeta o .exfat.") + empty = [n for n in vols if not ls[n].get("size")] + if empty: + add("bad", "LZ4: hay archivos de datos vacíos (%s)." % ", ".join(empty[:4]), "Vuelve a enviarla.") + fl = [d for d in ("fakelib", "fakelib2") if d in ls] + has_ampr = False + for d in fl: + try: + has_ampr |= any("ampr" in n.lower() for n, _ in E.ftp_listdir(f, path + "/" + d)) + except ftplib.all_errors: + pass + if has_ampr: + add("ok", "LZ4: lleva el emulador AMPR en su fakelib.") + else: + add("warn", "LZ4: no encuentro el emulador AMPR (libSceAmpr) en fakelib.", + "Sin él los datos empaquetados no se pueden leer. Vuelve a convertirla.") + # backports de otras carpetas que pueden pisar los de esta + tid = (info.get("tid") or E.title_id(posixpath.basename(path)) or "").upper() + if tid: + for base in ("/data/shadowmount/backports", "/data/shadowmountplus/backports", WORK_ROOT + "/backports"): + try: + names = [n for n, v in E.ftp_listdir(f, base) if tid in n.upper()] + except ftplib.all_errors: + continue + for n in names: + add("warn", "Hay un backport aparte para este título en %s/%s, que puede sustituir al suyo." % (base, n), + "Si se cierra al arrancar, prueba a apartar esa carpeta.") + since = _console_now(f) - minutes * 60 + info["logs"] = _recent_logs(f, ([path] if is_dir else []) + ["/data"], since) + E.Ftp(cfg).call(run) + lib = (LIB.get("items") or {}).get("items") or [] + me = next((x for x in lib if x.get("path") == path), {}) + if me.get("dup"): + add("warn", "Está repetida en %s: dos copias con el mismo Title ID pueden liar a ShadowMountPlus." % ", ".join(me["dup"]), + "Deja solo una.") + try: + caps = media_list()[:4] + except Exception: + caps = [] + info.update(path=path, checks=checks, captures=caps, codes=LAUNCH_CODES) + return info + + +COL = {"busy": False, "items": None, "error": "", "t": 0, "current": ""} +COL_EXT = re.compile(r"(?i)\.(exfat|ffpkg|ffpfs|ffpfsc|pkg|zip|7z|rar|001)$") + + +def _local_dir_size(path, limit_s=8.0): + t0, total = time.time(), 0 + for d, _dirs, fs in os.walk(path): + for n in fs: + try: + total += os.path.getsize(os.path.join(d, n)) + except OSError: + pass + if time.time() - t0 > limit_s: + return None + return total + + +def collection_scan(): + """Lee las carpetas de la coleccion del PC: aplicaciones en carpeta (sce_sys), imagenes, .pkg y comprimidos.""" + roots = [r for r in (SETTINGS.get("pc_collection") or []) if r] + with LOCK: + COL.update(busy=True, error="", current="") + items = [] + try: + for root in roots: + if not os.path.isdir(root): + items.append({"path": root, "kind": "missing", "name": root}) + continue + for d, dirs, fs in os.walk(root): + depth = d[len(root):].count(os.sep) + with LOCK: + COL["current"] = d + if "sce_sys" in dirs: + facts = {} + pj = os.path.join(d, "sce_sys", "param.json") + if os.path.isfile(pj): + try: + with open(pj, encoding="utf-8-sig", errors="replace") as fh: + facts = F.param_facts(fh.read()) + except OSError: + pass + sfo = os.path.join(d, "sce_sys", "param.sfo") + if not facts.get("tid") and os.path.isfile(sfo): + with open(sfo, "rb") as fh: + facts = F.sfo_facts(fh.read()) + items.append({"path": d, "kind": "folder", "name": os.path.basename(d), + "tid": facts.get("tid") or E.title_id(os.path.basename(d)), + "title": facts.get("title"), "version": facts.get("version"), + "need_fw": facts.get("fw_param") or facts.get("need_fw"), + "size": _local_dir_size(d), "icon": os.path.isfile(os.path.join(d, "sce_sys", "icon0.png"))}) + dirs[:] = [] + continue + if depth >= 4: + dirs[:] = [] + for n in sorted(fs): + if not COL_EXT.search(n): + continue + low = n.lower() + # de los comprimidos por partes solo cuenta la primera (.part1.rar / .001) + if re.search(r"\.part0*([2-9]|[1-9]\d+)\.rar$", low): + continue + fp = os.path.join(d, n) + try: + sz = os.path.getsize(fp) + except OSError: + sz = None + kind = "image" if re.search(r"(?i)\.(exfat|ffpkg|ffpfs|ffpfsc)$", n) else ( + "pkg" if low.endswith(".pkg") else "archive") + tid = E.title_id(n) + t = (lib_load().get("titles") or {}).get((tid or "").upper()) or {} + items.append({"path": fp, "kind": kind, "name": n, "tid": tid, "title": t.get("title"), + "size": sz, "ext": posixpath.splitext(low)[1].lstrip(".")}) + with LOCK: + COL.update(items=items, t=time.time()) + except Exception as e: + traceback.print_exc() + with LOCK: + COL["error"] = str(e) + finally: + with LOCK: + COL.update(busy=False, current="") + + +def col_icon(path): + path = os.path.abspath(path) + if not any(path.startswith(os.path.abspath(r)) for r in (SETTINGS.get("pc_collection") or []) if r): + return None + fp = os.path.join(path, "sce_sys", "icon0.png") + if os.path.isfile(fp): + with open(fp, "rb") as fh: + return fh.read() + return None + + +def media_list(): + """Capturas y videos de la consola (los mas nuevos primero).""" + cfg = cfg_snapshot() + out = [] + files = E.Ftp(cfg).call(lambda f: _ftp_walk(f, MEDIA_ROOT, [], threading.Event())) + for p, sz in files: + ext = posixpath.splitext(p)[1].lower() + if ext not in MEDIA_TYPES or "/thumbnail" in p.lower(): + continue + out.append({"path": p, "name": posixpath.basename(p), "size": sz, + "video": MEDIA_TYPES[ext].startswith("video"), + "folder": posixpath.basename(posixpath.dirname(p))}) + out.sort(key=lambda x: x["name"], reverse=True) + return out[:3000] + + +def media_cached(path): + """Copia local (cache) de una captura o video, para verla en el navegador.""" + import hashlib + path = fm_clean(path) + if not path.startswith(MEDIA_ROOT + "/"): + raise E.PipeError("Ruta no permitida") + ext = posixpath.splitext(path)[1].lower() + if ext not in MEDIA_TYPES: + raise E.PipeError("Tipo no admitido") + os.makedirs(MEDIA_DIR, exist_ok=True) + fp = os.path.join(MEDIA_DIR, hashlib.sha1(path.encode("utf-8")).hexdigest()[:20] + ext) + if not os.path.isfile(fp): + with open(fp + ".part", "wb") as fo: + E.Ftp(cfg_snapshot()).call(lambda f: f.retrbinary("RETR " + path, fo.write, 1024 * 1024)) + os.replace(fp + ".part", fp) + return fp, MEDIA_TYPES[ext] FMT_LABEL = {"plain": "carpeta normal", "ffpkg": "imagen .ffpkg", "exfat": "imagen .exfat", "ffpfs": "imagen .ffpfs", "ffpfsc": "imagen comprimida .ffpfsc", "fpkg": "paquete .fpkg", "lz4": "carpeta LZ4", "lz4ffpkg": "imagen .ffpkg con LZ4"} @@ -1766,10 +2365,59 @@ def remember_title(tid, title=None, need_fw=None, fw_param=None, cover_data_url= lib_save(d) -def ensure_forge(job, cfg): - exe = F.find_forge(cfg.get("forge_path"), DATA_DIR) - if exe: +def forge_maybe_update(job, cfg, exe): + """Una vez al dia, al convertir: si hay un PS5 Dump Forge mas nuevo en GitHub, lo baja y lo usa. Antes comprueba + que entiende las opciones que usa PS5 Directo; si no, sigue con la version que ya funcionaba.""" + import shutil + if (cfg.get("forge_path") or "").strip() or not cfg.get("forge_auto_update", True): return exe + now = time.time() + if now - float(SETTINGS.get("forge_checked") or 0) < 24 * 3600: + return exe + with LOCK: + SETTINGS["forge_checked"] = now + save_settings() + try: + tag, assets = F.github_release(F.FORGE_REPO) + except Exception as e: + jlog(job, "No pude mirar si hay PS5 Dump Forge nuevo (%s): sigo con el que hay" % e) + return exe + safe = re.sub(r"[^\w.-]", "_", tag or "") + bad = set(SETTINGS.get("forge_bad") or []) + have = os.path.basename(os.path.dirname(exe)) + if not safe or safe == have or safe in bad or os.path.isdir(os.path.join(DATA_DIR, "tools", "forge", safe)): + return exe + setj(job, phase="Actualizando PS5 Dump Forge a la %s" % tag) + try: + new, _ = F.download_forge(DATA_DIR, log=lambda m: jlog(job, m), release=(tag, assets)) + except Exception as e: + jlog(job, "No pude bajar PS5 Dump Forge %s (%s): sigo con el que hay" % (tag, e)) + return exe + ok = F.forge_cli_ok(new) + if ok is False: + with LOCK: + SETTINGS["forge_bad"] = sorted(bad | {safe}) + save_settings() + shutil.rmtree(os.path.dirname(new), ignore_errors=True) + jlog(job, "PS5 Dump Forge %s ha cambiado sus opciones y PS5 Directo aún no las conoce: sigo con %s" % (tag, have)) + notify("warn", "PS5 Dump Forge %s no es compatible todavía" % tag, + "Sigo usando la %s, que funciona. Saldrá una versión de PS5 Directo que la admita." % have, + key="forgebad:" + safe) + return exe + jlog(job, "PS5 Dump Forge actualizado a la %s (antes %s)" % (tag, have)) + # se guardan las dos ultimas versiones; las mas viejas se borran + root = os.path.join(DATA_DIR, "tools", "forge") + olds = sorted((d for d in os.listdir(root) if d not in (safe, have)), + key=lambda d: os.path.getmtime(os.path.join(root, d))) + for d in olds: + shutil.rmtree(os.path.join(root, d), ignore_errors=True) + return new + + +def ensure_forge(job, cfg): + exe = F.find_forge(cfg.get("forge_path"), DATA_DIR, skip=tuple(SETTINGS.get("forge_bad") or ())) + if exe: + return forge_maybe_update(job, cfg, exe) with FORGE_LOCK: exe = F.find_forge(cfg.get("forge_path"), DATA_DIR) if exe: @@ -1785,6 +2433,231 @@ def ensure_forge(job, cfg): return exe +# --------------------------------------------------------------------------- +# Torrents (magnet o .torrent): se bajan al PC con aria2 y luego siguen como un archivo del PC +# --------------------------------------------------------------------------- + +ARIA2_URL = "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip" +ARIA2_LOCK = threading.Lock() +_UNITS = {"B": 1, "KIB": 1024, "MIB": 1024 ** 2, "GIB": 1024 ** 3, "TIB": 1024 ** 4} +_ARIA_RX = re.compile(r"\[#(\w+)\s+([\d.]+)(\w*B)/([\d.]+)(\w*B)(?:\((\d+)%\))?([^\]]*)\]") +_PLAY_RX = re.compile(r"(?i)\.(rar|r\d{2,3}|zip|z\d{2}|7z|\d{3}|tar|tgz|gz|bz2|xz|exfat|ffpkg|ffpfs|ffpfsc|ufs|pkg|" + r"sfv|md5|sha1)$") + + +def is_torrent(line): + t = (line or "").strip().strip('"') + return bool(re.match(r"(?i)^magnet:\?", t)) or t.lower().split("?")[0].endswith(".torrent") + + +def _bytes(num, unit): + return int(float(num) * _UNITS.get(unit.upper(), 1)) + + +def torrent_name(line): + t = line.strip().strip('"') + m = re.search(r"(?i)[?&]dn=([^&]+)", t) + if m: + return urllib.parse.unquote_plus(m.group(1)) + base = t.split("?")[0].replace("\\", "/").rsplit("/", 1)[-1] + return re.sub(r"(?i)\.torrent$", "", urllib.parse.unquote(base)) or "torrent" + + +def ensure_aria2(job, cfg): + import shutil + custom = (cfg.get("aria2_path") or "").strip().strip('"') + if custom and os.path.isfile(custom): + return custom + mine = os.path.join(DATA_DIR, "tools", "aria2", "aria2c.exe" if os.name == "nt" else "aria2c") + if os.path.isfile(mine): + return mine + local = os.path.join(HERE, "aria2c.exe" if os.name == "nt" else "aria2c") + if os.path.isfile(local): + return local + w = shutil.which("aria2c") + if w: + return w + if os.name != "nt": + raise E.PipeError("Para bajar torrents hace falta aria2 (instálalo, por ejemplo: sudo apt install aria2)") + with ARIA2_LOCK: + if os.path.isfile(mine): + return mine + setj(job, phase="Descargando aria2 para los torrents (solo la primera vez)") + jlog(job, "Descargando aria2 (el programa que baja los torrents)…") + os.makedirs(os.path.dirname(mine), exist_ok=True) + tmp = mine + ".zip" + try: + F.http_get(ARIA2_URL, limit=50 * 1024 * 1024, timeout=120, dest=tmp) + import zipfile + with zipfile.ZipFile(tmp) as z: + n = next((x for x in z.namelist() if x.lower().endswith("/aria2c.exe")), None) + if not n: + raise E.PipeError("el .zip de aria2 no trae aria2c.exe") + with z.open(n) as src, open(mine + ".tmp", "wb") as dst: + shutil.copyfileobj(src, dst) + os.replace(mine + ".tmp", mine) + except Exception as e: + raise E.PipeError("No pude descargar aria2 para los torrents (%s). Bájalo de github.com/aria2/aria2 y " + "copia aria2c.exe en la carpeta de PS5 Directo." % e) + finally: + try: + os.remove(tmp) + except OSError: + pass + jlog(job, "aria2 listo") + return mine + + +def run_aria2(job, exe, line, root, cancel): + """Baja un torrent a root. Devuelve cuando ha terminado (sin compartir despues).""" + import subprocess + import shutil + t = line.strip().strip('"') + args = [exe, "--dir=" + root, "--seed-time=0", "--bt-save-metadata=true", "--continue=true", + "--file-allocation=none", "--summary-interval=1", "--show-console-readout=false", + "--console-log-level=warn", "--enable-color=false", "--auto-save-interval=5", + "--bt-detach-seed-only=true", "--auto-file-renaming=false", "--allow-overwrite=false", + "--check-certificate=false" if os.name != "nt" else "--check-certificate=true"] + if not re.match(r"(?i)^(magnet:|https?://)", t): + if not os.path.isfile(t): + raise E.PipeError("No encuentro el archivo .torrent: %s" % t) + args += ["--torrent-file=" + t] + else: + args += [t] + name = torrent_name(t) + with LOCK: + if not job.get("title"): + job["title"] = name + job["sources"] = [{"input": t[:200], "name": name, "size": -1, "kind": "url"}] + setj(job, status="enviando", pc_step="bajar", phase="Torrent: buscando pares y datos del torrent…", + speed_label="Descarga del torrent", sent_label="Bajado al PC", sent=0, total=-1, speed=0.0, dl_speed=0.0) + jlog(job, "Bajando el torrent al PC con aria2 en %s" % root) + os.makedirs(root, exist_ok=True) + proc = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + creationflags=E._NOWIN, cwd=root) + tail, checked = [], {"space": False} + + def reader(): + for raw in proc.stdout: + ln = raw.decode("utf-8", "replace").rstrip() + if not ln: + continue + m = _ARIA_RX.search(ln) + if not m: + if ln.startswith(("FILE:", "***", "===", "---")) or "Download Progress Summary" in ln: + continue + # aria2 avisa de cosas sin importancia (DHT, IPv6) como "ERROR": solo se muestran si al final falla + tail.append(ln) + del tail[:-15] + continue + done, tot = _bytes(m.group(2), m.group(3)), _bytes(m.group(4), m.group(5)) + rest = m.group(7) or "" + dl = re.search(r"DL:([\d.]+)(\w*B)", rest) + cn = re.search(r"CN:(\d+)", rest) + sd = re.search(r"SD:(\d+)", rest) + speed = _bytes(dl.group(1), dl.group(2)) if dl else 0 + if tot <= 0 or "[METADATA]" in ln: + setj(job, phase="Torrent: consiguiendo los datos del torrent (%s conexiones)" % (cn.group(1) if cn else "0")) + continue + if not checked["space"]: + checked["space"] = True + free = shutil.disk_usage(root).free + with LOCK: + job["sources"] = [{"input": t[:200], "name": name, "size": tot, "kind": "url"}] + if tot + GiB > free: + job["_stop"] = ("No cabe en el PC: el torrent ocupa %s y en %s hay %s libres. Libera espacio o " + "cambia la carpeta temporal en Ajustes." % (human(tot), root, human(free))) + cancel.set() + peers = ("%s conexiones%s" % (cn.group(1), (", %s compartiendo" % sd.group(1)) if sd else "")) if cn else "" + setj(job, sent=done, total=tot, speed=float(speed), dl_speed=float(speed), + phase="Bajando el torrent al PC: %d%%%s" % (int(done * 100 / tot), (" · " + peers) if peers else "")) + th = threading.Thread(target=reader, daemon=True) + th.start() + while proc.poll() is None: + if cancel.is_set(): + try: + proc.terminate() + proc.wait(10) + except Exception: + proc.kill() + raise E.Cancelled() + time.sleep(0.5) + th.join(5) + if proc.returncode != 0: + for x in tail[-8:]: + jlog(job, "aria2: " + x[:200]) + msg = " ".join(x for x in tail[-4:] if x)[-300:] or ("código %s" % proc.returncode) + raise E.PipeError("No se pudo bajar el torrent: %s" % msg) + setj(job, sent=max(job.get("total") or 0, job.get("sent") or 0), speed=0.0, dl_speed=0.0) + jlog(job, "Torrent bajado al PC") + + +def torrent_outputs(root): + """Lo que se ha bajado, convertido en lineas para la cola: aplicaciones (carpetas con sce_sys), comprimidos e + imagenes; si no hay nada de eso, la carpeta tal cual.""" + tops = [os.path.join(root, n) for n in sorted(os.listdir(root)) + if not n.lower().endswith((".aria2", ".torrent")) and not n.startswith(".")] + apps, files = [], [] + for top in tops: + if os.path.isfile(top): + files.append(top) + continue + for d, dirs, fs in os.walk(top): + if "sce_sys" in dirs: + apps.append(d) + dirs[:] = [] + continue + dirs.sort() + for f in sorted(fs): + if not f.lower().endswith(".aria2"): + files.append(os.path.join(d, f)) + play = [f for f in files if _PLAY_RX.search(f)] + out = apps + play + if not out: + out = tops + return out + + +def torrent_stage(job, cfg, cancel): + lines = list(job["lines"]) + tor = [l for l in lines if is_torrent(l)] + rest = [l for l in lines if not is_torrent(l)] + with LOCK: + job.setdefault("orig_lines", lines) + job["torrent"] = True + exe = ensure_aria2(job, cfg) + root = os.path.join(pc_temp_root(cfg), "torrent", job["id"]) + for t in tor: + run_aria2(job, exe, t, root, cancel) + local = torrent_outputs(root) + if not local: + raise E.PipeError("El torrent no ha dejado nada en %s" % root) + jlog(job, "Del torrent sale: %s" % ", ".join(os.path.relpath(x, root) for x in local[:8]) + + (" y %d más" % (len(local) - 8) if len(local) > 8 else "")) + with LOCK: + job["lines"] = local + rest + job["torrent_dir"] = root + setj(job, status="comprobando", pc_step="enviar", speed_label="", sent_label="", sent=0, total=-1) + save_jobs() + + +def torrent_forget(job): + """Al quitar un trabajo de torrent de la cola, lo bajado al PC ya no sirve: se borra.""" + if not job.get("torrent"): + return + import shutil + d = job.get("torrent_dir") or os.path.join(pc_temp_root(cfg_snapshot()), "torrent", job["id"]) + threading.Thread(target=shutil.rmtree, args=(d, True), daemon=True).start() + + +def torrent_cleanup(job): + d = job.get("torrent_dir") + if d and os.path.isdir(d) and cfg_snapshot().get("delete_archives", True): + import shutil + shutil.rmtree(d, ignore_errors=True) + jlog(job, "Borrado del PC lo bajado del torrent") + + def check_fw_warning(job, tid, title, need_fw, fw_param=None, backport=False): """Aviso si el juego pide un firmware mas nuevo que el de tu PS5 (Ajustes).""" with LOCK: @@ -1844,6 +2717,76 @@ def send_local_as_is(job, cfg, path, cancel, up): return {"mode": "done", "placed": [target], "game": job["_placed_game"]} +def forge_split_archive(job, cfg, cancel, archive, cands): + """El comprimido trae varios juegos (lo normal: la version normal y un backport) y PS5 Dump Forge + solo convierte uno. Descomprime en el PC solo el que toca y devuelve su carpeta (None si no se puede).""" + import subprocess + import shutil + exe7 = E.find_7z(cfg.get("sevenzip_path") or "") + if not exe7: + jlog(job, "Trae varias aplicaciones y para separarlas hace falta 7-Zip, que no está instalado") + return None + entries, pw = None, None + for c in cands: + try: + entries, _ = E.list_7z(exe7, archive, c) + pw = c + break + except E.PipeError as e: + if not E._pw_error(str(e)): + jlog(job, str(e)) + return None + if entries is None: + return None + roots = sorted({e["path"][:-len("/sce_sys/param.json")] for e in entries + if e["path"].lower().endswith("/sce_sys/param.json")}) + if not roots: + return None + normal = [r for r in roots if not _backport_label(r)] + root = normal[0] if normal else roots[0] + others = [r for r in roots if r != root] + total = sum(e["size"] for e in entries if e["path"] == root or e["path"].startswith(root + "/")) + jlog(job, "Trae %d aplicaciones (%s): PS5 Dump Forge solo convierte una, así que descomprimo en el PC solo " + "%s y convierto eso" % (len(roots), ", ".join(roots), root)) + if others: + jlog(job, "Lo que no se usa: %s" % ", ".join(others)) + work = os.path.join(pc_temp_root(cfg), "forge", job["id"], "origen") + folder = os.path.join(work, *root.split("/")) + if os.path.isfile(os.path.join(work, ".hecho")) and os.path.isfile(os.path.join(folder, "sce_sys", "param.json")): + jlog(job, "Ya estaba descomprimido en el PC: %s" % root) + return folder + shutil.rmtree(work, ignore_errors=True) + os.makedirs(work, exist_ok=True) + free_pc = shutil.disk_usage(work).free + if total * 2 + GiB > free_pc: + raise E.PipeError("No cabe en el PC: para separarlo y convertirlo hacen falta unos %s y en %s hay %s " + "libres. Libera espacio o elige «Sin convertir»." % (human(total * 2 + GiB), work, + human(free_pc))) + setj(job, status="convirtiendo", pc_step="convertir", phase="Descomprimiendo %s en el PC para convertirlo" % root, + conv_pct=0, conv_text="") + proc = subprocess.Popen(E._7z_cmd(exe7, "x", archive, "-o" + work, root, password=pw), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + creationflags=E._NOWIN) + while proc.poll() is None: + if cancel.is_set(): + proc.kill() + raise E.Cancelled() + done = _dir_size(work) if os.path.isdir(work) else 0 + if total: + pct = min(99, int(done * 100 / total)) + setj(job, conv_pct=pct, conv_text="Descomprimiendo · %s de %s" % (human(done), human(total)), + phase="Descomprimiendo %s en el PC para convertirlo: %d%%" % (root, pct)) + time.sleep(0.5) + if proc.returncode != 0: + err = proc.stderr.read().decode("utf-8", "replace").strip() + raise E.PipeError("7-Zip no pudo descomprimirlo para convertirlo: %s" % err[-300:]) + if not os.path.isfile(os.path.join(folder, "sce_sys", "param.json")): + jlog(job, "No encuentro %s después de descomprimir" % root) + return None + open(os.path.join(work, ".hecho"), "w").close() + return folder + + def forge_job(job, srcs, cfg, cancel, src_path, created, pc_folder, up): """Convierte con PS5 Dump Forge en el PC y envia el resultado. None = mejor como carpeta normal.""" import shutil @@ -1864,8 +2807,16 @@ def forge_job(job, srcs, cfg, cancel, src_path, created, pc_folder, up): low = str(e).lower() if not any(w in low for w in ("password", "encrypt", "wrong", "contrase", "crc")): break + if info is None and last and "more than one ps5 game" in str(last).lower() and os.path.isfile(src_path): + sub = forge_split_archive(job, cfg, cancel, src_path, cands) + if sub: + return forge_job(job, srcs, cfg, cancel, sub, created, pc_folder, up) if info is None: jlog(job, "PS5 Dump Forge no puede leerlo: %s" % last) + if not any(w in str(last).lower() for w in ("password", "encrypt")): + with LOCK: + job["warning"] = ("No se ha podido convertir (PS5 Dump Forge: %s). Se ha enviado sin convertir." + % str(last)[:160]) if last and any(w in str(last).lower() for w in ("password", "encrypt")): raise E.PipeError("Ninguna contraseña abre el comprimido. Escríbela en el trabajo o en Ajustes y " "pulsa Reintentar (no se vuelve a descargar).") @@ -1902,9 +2853,18 @@ def forge_job(job, srcs, cfg, cancel, src_path, created, pc_folder, up): if fmt in ("lz4", "lz4ffpkg") and not lz4.get("imports_ampr"): jlog(job, "Este título no usa AMPR, así que LZ4 no sirve con él") if fmt == "lz4": + msg = ("PS5 Dump Forge dice que este título no usa AMPR, así que no se puede pasar a LZ4: " + "se envía sin convertir, como carpeta normal.") + with LOCK: + job["warning"] = msg + jlog(job, msg) return None fmt = "ffpkg" + with LOCK: + job["warning"] = ("PS5 Dump Forge dice que este título no usa AMPR: se envía como imagen .ffpkg " + "normal, sin LZ4.") jlog(job, "Lo convierto a imagen .ffpkg normal") + setj(job, fmt_real=fmt) if fmt in ("lz4", "lz4ffpkg"): jlog(job, "LZ4 es experimental (ampr_emu de prueba): con algunos títulos se cuelga al guardar partida") check_fw_warning(job, tid, title, need_fw, info.get("firmware"), bool(info.get("backport"))) @@ -1971,6 +2931,13 @@ def forge_job(job, srcs, cfg, cancel, src_path, created, pc_folder, up): up(v - last["v"]) last["v"] = v setj(job, sent=v, speed=up.rate()) + if target.rstrip("/") not in set(job.get("own_paths") or []) and bf.alive() and bf.exists(target): + # en el destino ya hay algo con ese nombre que no es de este trabajo: no se mezcla con lo convertido + if os.path.isdir(out) and not bf.exists(target + "/sce_sys/param.json"): + jlog(job, "En %s había un envío anterior a medias: lo sustituyo" % target) + bf.delete(target, cancel) + else: + target = resolve_existing(job, target, bf.exists, lambda t: bf.delete(t, cancel), tid) claim_target(job, target) watch = start_space_watch(job, bf, cancel, "el envio") try: @@ -2314,6 +3281,21 @@ def ftp_place(job, info, dest, cfg): raise E.PipeError("No encuentro en la PS5 lo enviado (%s). Pulsa Reintentar para mandarlo " "de nuevo." % staging) games = info.get("games") or [] + mains = [g for g in games if not _backport_label(g.get("root") or "")] + if mains and len(mains) < len(games): + for g in games: + label = _backport_label(g.get("root") or "") + if not label: + continue + _, msg = _backport_msg(job, label, g.get("tid") or "?", None) + aside = "%s/backports/%s-%s" % (WORK_ROOT, g.get("tid") or "x", E.safe_name(label)) + try: + E.Ftp.mkdirs(f, WORK_ROOT + "/backports") + f.rename(staging + "/" + g["root"], aside) + jlog(job, msg + " Lo dejo en %s." % aside) + except Exception as e: + jlog(job, "No pude apartar el backport (%s): %s" % (g["root"], e)) + games = mains moves = [] if games: for g in games: @@ -2326,8 +3308,8 @@ def ftp_place(job, info, dest, cfg): placed = [] for src, target in moves: if is_dir(target): - raise E.PipeError("Ya existe %s. No lo piso: borralo o renombralo y pulsa Reintentar. " - "Lo enviado esta en %s" % (target, src)) + target = resolve_existing(job, target, is_dir, lambda t: ftp_rmtree(cfg, t, threading.Event()), + E.title_id(target)) E.Ftp.mkdirs(f, dest) f.rename(src, target) jlog(job, "Colocado: %s" % target) @@ -2632,9 +3614,91 @@ def warn_duplicates(job, bf, tid, exclude=None, when=""): job["warning"] = msg +def resolve_existing(job, target, exists, delete, tid=None): + """Al colocar, si en el destino ya hay algo con ese nombre: lo decide el usuario (una vez). + Reemplazar = se borra lo que habia; Enviar igualmente = se coloca al lado con otro nombre. + Devuelve el nombre final, o lanza NeedsDecision para que el trabajo se pare a preguntar.""" + choice = job.get("dup_choice") + if choice == "replace": + jlog(job, "Borro la copia que ya había en %s" % target) + setj(job, phase="Borrando la copia anterior: %s" % target) + delete(target) + return target + if choice == "send": + k = 2 + while exists("%s-%d" % (target, k)): + k += 1 + alt = "%s-%d" % (target, k) + msg = ("Ya tenías %s: esta copia queda como %s. Dos copias con el mismo Title ID pueden liar a " + "ShadowMountPlus (deja solo una)." % (target, alt)) + jlog(job, msg) + with LOCK: + job["warning"] = msg + return alt + with LOCK: + job["ask"] = {"kind": "dup", "tid": tid or posixpath.basename(target), "paths": [target], "at": "place"} + jlog(job, "Ya existe %s: espero a que elijas" % target) + raise NeedsDecision() + + +def _backport_label(rel): + """'backport (4.xx)' si alguna carpeta de la ruta se llama asi; None si no.""" + for seg in rel.strip("/").split("/"): + if "backport" in seg.lower(): + return seg + return None + + +def _backport_msg(job, label, tid, need_fw): + mine = (SETTINGS.get("console_fw") or "").strip() + ok = F.fw_ok(mine, need_fw) if mine and need_fw else None + if ok: + return ok, ("El comprimido trae también un %s de %s para firmwares más bajos. Tu PS5 (%s) puede abrir " + "la versión normal, así que coloco esa y no el backport." % (label, tid, mine)) + return ok, ("El comprimido trae la versión normal de %s y un %s. Coloco la normal; el backport lo dejo aparte " + "por si tu firmware lo necesita." % (tid, label)) + + +def split_backports(job, bf, found, staging): + """Si lo extraido trae la aplicacion normal y ademas un 'backport' con el mismo Title ID, solo se coloca la + normal (antes salian las dos con el mismo nombre y la segunda daba 'Ya existe'). El backport se aparta.""" + games = [p for k, p in found if k == "game"] + mains = [p for p in games if not _backport_label(p[len(staging):])] + bps = [p for p in games if _backport_label(p[len(staging):])] + if not mains or not bps: + return found + try: + facts = F.param_facts(bf.read_text(mains[0] + "/sce_sys/param.json")) + except Exception: + facts = {} + tid = facts.get("tid") or E.title_id(mains[0]) or "?" + for bp in bps: + label = _backport_label(bp[len(staging):]) + ok, msg = _backport_msg(job, label, tid, facts.get("fw_param")) + jlog(job, msg) + if ok: + try: + bf.delete(bp) + except Exception: + pass + else: + aside = "%s/backports/%s-%s" % (WORK_ROOT, tid, E.safe_name(label)) + try: + bf.mkdir(WORK_ROOT + "/backports") + if bf.exists(aside): + bf.delete(aside) + bf.rename(bp, aside) + jlog(job, "Backport apartado en %s: si tu firmware lo necesita, copia su contenido encima de la " + "carpeta de la aplicación." % aside) + except Exception as e: + jlog(job, "No pude apartar el backport (%s): %s" % (bp, e)) + return [(k, p) for k, p in found if not (k == "game" and p in bps)] + + def place(job, bf, staging, dest, cancel, hint_name): found = find_payloads(bf, staging) placed = [] + found = split_backports(job, bf, found, staging) if not found: jlog(job, "No encontre sce_sys ni imagenes: dejo todo junto en una carpeta") found = [("folder", staging)] @@ -2656,9 +3720,12 @@ def place(job, bf, staging, dest, cancel, hint_name): target = target_dir + "/" + name if kind in ("game", "image"): warn_duplicates(job, bf, E.title_id(name), exclude=target) + if bf.exists(target) and kind == "game" and not bf.exists(target + "/sce_sys/param.json"): + # resto de un envio anterior que se quedo a medias: no es una aplicacion, se sustituye + jlog(job, "En %s había un envío anterior a medias: lo sustituyo" % target) + bf.delete(target, cancel) if bf.exists(target): - raise E.PipeError("Ya existe %s. No lo piso: borralo o renombralo y pulsa Reintentar. " - "Lo extraido esta en %s" % (target, path)) + target = resolve_existing(job, target, bf.exists, lambda t: bf.delete(t, cancel), E.title_id(name)) bf.mkdir(target_dir) bf.rename(path, target) jlog(job, "Colocado: %s" % target) @@ -2728,6 +3795,7 @@ def _extract_job(job, info, cancel): low = name.lower() target_dir = "/data/pkg" if low.endswith(".pkg") else dest target = target_dir + "/" + name + box = {"target": target} def go(f): try: @@ -2735,13 +3803,20 @@ def _extract_job(job, info, cancel): except ftplib.error_perm: E.Ftp.mkdirs(f, target_dir) if E.Ftp.size(f, target) is not None: - raise E.PipeError("Ya existe %s; no lo piso. Lo enviado esta en %s" % (target, info["path"])) - f.rename(info["path"], target) + base, ext = os.path.splitext(target) + + def ftp_del(t): + f.delete(t) + t2 = resolve_existing(job, base, lambda t: E.Ftp.size(f, t + ext) is not None, + lambda t: ftp_del(t + ext), E.title_id(name)) + box["target"] = t2 + ext + f.rename(info["path"], box["target"]) try: f.rmd(info["path"].rsplit("/", 1)[0]) except ftplib.all_errors: pass E.Ftp(cfg).call(go) + target = box["target"] jlog(job, "Colocado: %s" % target) if low.endswith(E.IMAGE_EXTS): job["_placed_game"] = True @@ -2754,7 +3829,9 @@ def _extract_job(job, info, cancel): target_dir = "/data/pkg" if low.endswith(".pkg") else dest target = target_dir + "/" + name if bf.exists(target): - raise E.PipeError("Ya existe %s; no lo piso. Lo enviado esta en %s" % (target, info["path"])) + base, ext = os.path.splitext(target) + target = resolve_existing(job, base, lambda t: bf.exists(t + ext), lambda t: bf.delete(t + ext, cancel), + E.title_id(name)) + ext bf.mkdir(target_dir) bf.rename(info["path"], target) jlog(job, "Colocado: %s" % target) @@ -2969,6 +4046,8 @@ def transfer_worker(): cancel.clear() job["status"] = "comprobando" try: + if any(is_torrent(x) for x in job["lines"]): + torrent_stage(job, cfg_snapshot(), cancel) srcs = probe_job(job, cancel) sets = split_into_sets(srcs) if len(sets) > 1: @@ -3045,6 +4124,11 @@ def extract_worker(): else "Listo. Copiado en la PS5")) jlog(job, "Terminado") record_history(job) + except NeedsDecision: + setj(job, status="pausado", phase="Ya está en la consola: elige qué hacer", speed=0.0) + notify("warn", "Ya está en la PS5: %s" % (job.get("title") or ""), + "Elige en la cola si lo colocas igualmente, reemplazas la que hay o cancelas.", + {"tab": "Transfers", "job": job["id"]}) except E.Cancelled: stopped(job) except Exception as e: @@ -3101,6 +4185,61 @@ LZ4_NAME_RE = re.compile(r"(?i)(^|[-_. \[(])lz4([-_. \])]|$)") LIB_IMAGE_RE = re.compile(r"\.(ffpkg|exfat|ffpfsc|ffpfs|ufs2?)$", re.I) +GU_TID = "PPSA99440" # PS5 Game Updater (theghostonline): su web va en el puerto 7920 + + +def gu_status(): + """¿Esta PS5 Game Updater en la consola, bien colocado y en marcha?""" + cfg = cfg_snapshot() + out = {"present": False, "running": False, "url": "", "fix_from": "", "problem": ""} + + def look(f): + names = {n: v for n, v in E.ftp_listdir(f, "/data/homebrew")} + if GU_TID in names and names[GU_TID]["type"] == "dir": + sub = {n for n, _ in E.ftp_listdir(f, "/data/homebrew/" + GU_TID)} + out["present"] = "sce_sys" in sub + if not out["present"]: + out["problem"] = "La carpeta /data/homebrew/%s no tiene sce_sys: está incompleta." % GU_TID + for n, v in names.items(): + if n == GU_TID or GU_TID not in n.upper() or v["type"] != "dir": + continue + p = "/data/homebrew/" + n + sub = {k: w for k, w in E.ftp_listdir(f, p)} + if "sce_sys" in sub: + out["fix_from"] = p + else: + inner = [k for k, w in sub.items() if w["type"] == "dir"] + for k in inner: + if "sce_sys" in {x for x, _ in E.ftp_listdir(f, p + "/" + k)}: + out["fix_from"] = p + "/" + k + break + if out["fix_from"]: + out["problem"] = ("Está en %s, pero tiene que estar en /data/homebrew/%s para que funcione." + % (out["fix_from"], GU_TID)) + break + port = 7920 + try: + raw = F.ftp_read(f, "/data/GameUpdater/engine.port", 0, 32).decode("ascii", "ignore").strip() + if raw.isdigit(): + port = int(raw) + except Exception: + pass + return port + try: + port = E.Ftp(cfg).call(look) + except Exception as e: + out["problem"] = "No pude mirarlo: %s" % e + return out + out["url"] = "http://%s:%d/" % (cfg["ps5_ip"], port) + try: + s = socket.create_connection((cfg["ps5_ip"], port), timeout=1.5) + s.close() + out["running"] = True + except OSError: + pass + return out + + def library_bases(bf, f): if bf.alive(): try: @@ -3162,6 +4301,52 @@ def library_scan(refresh=False): work.append((base, n, x)) with LOCK: LIB.update(total=len(work), done=0) + # imagenes montadas por ShadowMountPlus: /mnt/shadowmnt/_ (y pfsc/...). Dentro estan + # su param.json y su portada, asi que las imagenes tambien tienen nombre y portada. + mounts = [] + for mb in ("/mnt/shadowmnt", "/mnt/shadowmnt/pfsc"): + try: + ml = listdir(mb) + except ftplib.all_errors: + try: + ml = E.ftp_listdir(ftp(), mb) # BFpilot no siempre lista los puntos de montaje + except ftplib.all_errors: + continue + for n, v in ml: + if v["type"] != "dir" or n == "pfsc": + continue + mp = mb + "/" + n + c = ents.get("mount:" + mp) + if c and c.get("mtime") == v.get("mtime") and (c.get("facts") or {}).get("v") == 4: + facts = c["facts"] + else: + try: + try: + sub = listdir(mp) + except ftplib.all_errors: + sub = E.ftp_listdir(ftp(), mp) + if not any(k == "sce_sys" and w["type"] == "dir" for k, w in sub): + continue + facts = E.Ftp(cfg).call(lambda fx: F.inspect_remote_game(fx, mp, sub)) + if any(k.lower().startswith(("ampr_assets", "ampr_emu")) for k, w in sub): + facts["lz4"] = True + facts["v"] = 4 if facts.get("tid") else 0 + except ftplib.all_errors: + continue + ents["mount:" + mp] = {"mtime": v.get("mtime"), "facts": facts} + mounts.append({"path": mp, "key": n.rsplit("_", 1)[0].lower(), "facts": facts}) + + def mount_for(fname, tid): + low = fname.lower() + stem = low.rsplit(".", 1)[0] + for m in mounts: + if m["key"] in (low, stem): + return m + if tid: + for m in mounts: + if (m["facts"].get("tid") or "").upper() == tid.upper(): + return m + return None for i, (base, name, x) in enumerate(work): if LIB_CANCEL.is_set(): raise E.Cancelled() @@ -3171,19 +4356,23 @@ def library_scan(refresh=False): drive = "USB" if base.startswith("/mnt/") else "Interno" if x["type"] == "dir": c = ents.get(path) - if c and not refresh and c.get("mtime") == x.get("mtime") and (c.get("facts") or {}).get("v") == 2: + if c and not refresh and c.get("mtime") == x.get("mtime") and (c.get("facts") or {}).get("v") == 4: facts = c["facts"] else: try: listing = listdir(path) if any(n == "sce_sys" and v["type"] == "dir" for n, v in listing): - facts = F.inspect_remote_game(ftp(), path, listing) + # una conexion por juego (como las portadas): leer el eboot.bin a medias deja + # algunas conexiones descolocadas y fallaban las lecturas de los siguientes + facts = E.Ftp(cfg).call(lambda fx: F.inspect_remote_game(fx, path, listing)) + if not facts.get("tid") and facts.get("icon"): + facts = E.Ftp(cfg).call(lambda fx: F.inspect_remote_game(fx, path, listing)) # carpeta empaquetada en LZ4 (PS5 Dump Forge): lleva ampr_assets.index - if any(n == "ampr_assets.index" for n, v in listing): + if any(n.lower().startswith(("ampr_assets", "ampr_emu")) for n, v in listing): facts["lz4"] = True else: facts = {"incomplete": True} - facts["v"] = 2 + facts["v"] = 4 if (facts.get("tid") or facts.get("incomplete") or not facts.get("icon")) else 0 except ftplib.all_errors as e: facts = {"error": str(e)} c = {"mtime": x.get("mtime"), "facts": facts} @@ -3193,6 +4382,14 @@ def library_scan(refresh=False): cover=bool(facts.get("icon"))) if not it.get("incomplete") and LZ4_NAME_RE.search(name): it["lz4"] = True + if not it.get("tid") and not it.get("incomplete"): + # sin param.json legible: el Title ID del nombre y el titulo si ya lo conociamos + tn = E.title_id(name) + if tn: + it["tid"] = tn + known = titles.get(tn.upper()) or {} + it["title"] = it.get("title") or known.get("title") + it["need_fw"] = it.get("need_fw") or known.get("need_fw") if it.get("tid"): t = titles.setdefault(it["tid"].upper(), {}) if it.get("title"): @@ -3202,15 +4399,114 @@ def library_scan(refresh=False): items.append(it) elif LIB_IMAGE_RE.search(name): tid = E.title_id(name) + m = mount_for(name, tid) + if m and not tid: + tid = m["facts"].get("tid") t = titles.get((tid or "").upper(), {}) - items.append({"path": path, "name": name, "kind": "image", "drive": drive, - "lz4": bool(LZ4_NAME_RE.search(name)), - "ext": LIB_IMAGE_RE.search(name).group(1).lower(), "size": x.get("size"), - "tid": tid, "title": t.get("title"), "need_fw": t.get("need_fw"), - "backport": t.get("backport"), "cover": bool(t.get("cover"))}) + mf = m["facts"] if m else {} + it = {"path": path, "name": name, "kind": "image", "drive": drive, + "lz4": bool(LZ4_NAME_RE.search(name)) or bool(mf.get("lz4")), + "ext": LIB_IMAGE_RE.search(name).group(1).lower(), "size": x.get("size"), + "tid": tid, "title": mf.get("title") or t.get("title"), + "version": mf.get("version"), "need_fw": mf.get("need_fw") or t.get("need_fw"), + "backport": mf.get("backport") or t.get("backport"), + "cover": bool(mf.get("icon") or t.get("cover"))} + if m and mf.get("icon"): + it["cover_path"] = m["path"] + if m: + it["mounted"] = m["path"] + if tid and it["title"]: + tt = titles.setdefault(tid.upper(), {}) + tt["title"] = it["title"] + if it.get("need_fw"): + tt["need_fw"] = it["need_fw"] + items.append(it) + # portadas y nombres que faltan: la PS5 guarda los de cada aplicacion registrada en su menu en + # /user/appmeta// (icon0.png y param.json). Sirve para imagenes y carpetas sin param.json. + try: + meta_list = E.Ftp(cfg).call(lambda fx: E.ftp_listdir(fx, "/user/appmeta")) + except ftplib.all_errors: + meta_list = [] + meta_ids = {n.upper() for n, v in meta_list if v["type"] == "dir"} + # aplicaciones instaladas en la consola (PS5 y PS4) que no son copias de /data/homebrew + have = {(it.get("tid") or "").upper() for it in items} + try: + user_app = {n.upper() for n, v in listdir("/user/app")} + except ftplib.all_errors: + user_app = set() + for n, v in sorted(meta_list): + tidu = n.upper() + if v["type"] != "dir" or not re.match(r"^(PPSA|CUSA)\d{5}$", tidu) or tidu in have: + continue + mp = "/user/appmeta/" + n + c = ents.get("installed:" + tidu) + if not (c and not refresh and c.get("mtime") == v.get("mtime") and (c.get("facts") or {}).get("v") == 4): + try: + def look(fx, mp=mp, tidu=tidu): + names = {k for k, _ in E.ftp_listdir(fx, mp)} + out = {"icon": "icon0.png" in names, "v": 4} + if "param.json" in names: + raw = F.ftp_read(fx, mp + "/param.json", 0, 512 * 1024) + out.update(F.param_facts(raw.decode("utf-8-sig", "replace"))) + out["need_fw"] = out.get("fw_param") + elif "param.sfo" in names: + out.update(F.sfo_facts(F.ftp_read(fx, mp + "/param.sfo", 0, 256 * 1024))) + return out + facts = E.Ftp(cfg).call(look) + except ftplib.all_errors: + continue + c = ents["installed:" + tidu] = {"mtime": v.get("mtime"), "facts": facts} + mf = c["facts"] + if mf.get("extra"): + continue + if mf.get("title"): + titles.setdefault(tidu, {})["title"] = mf["title"] + ps4 = tidu.startswith("CUSA") + items.append({"path": "/user/app/" + n if tidu in user_app else mp, "name": n, "kind": "installed", + "platform": "PS4" if ps4 else "PS5", "drive": "Interno", "tid": tidu, + "title": mf.get("title"), "version": mf.get("version"), "need_fw": mf.get("need_fw"), + "cover": bool(mf.get("icon")), "cover_path": mp if mf.get("icon") else None, + "size": (ents.get("/user/app/" + n) or {}).get("size")}) + need = [it for it in items if it.get("tid") and not it.get("incomplete") and it["kind"] != "installed" + and (not it.get("cover") or not it.get("title"))] + if need: + for it in need: + tidu = it["tid"].upper() + if tidu not in meta_ids: + continue + mp = "/user/appmeta/" + tidu + c = ents.get("appmeta:" + tidu) + if not (c and (c.get("facts") or {}).get("v") == 4 and not refresh): + try: + def look(fx, mp=mp): + names = {n for n, _ in E.ftp_listdir(fx, mp)} + out = {"icon": "icon0.png" in names, "v": 4} + if "param.json" in names: + raw = F.ftp_read(fx, mp + "/param.json", 0, 512 * 1024) + out.update(F.param_facts(raw.decode("utf-8-sig", "replace"))) + return out + facts = E.Ftp(cfg).call(look) + except ftplib.all_errors: + continue + c = ents["appmeta:" + tidu] = {"facts": facts} + mf = c["facts"] + if mf.get("icon") and not it.get("cover"): + it["cover"] = True + it["cover_path"] = mp + if mf.get("title") and not it.get("title"): + it["title"] = mf["title"] + titles.setdefault(tidu, {})["title"] = mf["title"] + if mf.get("version") and not it.get("version"): + it["version"] = mf["version"] + # el firmware que pide su param.json (dentro de una imagen no se puede mirar el eboot.bin) + fwp = mf.get("need_fw") or mf.get("fw_param") + if fwp and not it.get("need_fw"): + it["need_fw"] = fwp + it["fw_from_param"] = True # tamano de las carpetas (rapido con BFpilot; por FTP seria muy lento) if alive: - todo = [it for it in items if it["kind"] == "folder" and it.get("size") is None] + todo = [it for it in items if (it["kind"] == "folder" or (it["kind"] == "installed" and it["path"].startswith("/user/app/"))) + and it.get("size") is None] for k, it in enumerate(todo): if LIB_CANCEL.is_set(): raise E.Cancelled() @@ -3221,7 +4517,9 @@ def library_scan(refresh=False): it["size"] = sz ents.setdefault(it["path"], {})["size"] = sz # quitar de la cache lo que ya no esta - seen = {it["path"] for it in items} + seen = {it["path"] for it in items} | {"mount:" + m["path"] for m in mounts} | \ + {"appmeta:" + it["tid"].upper() for it in items if it.get("tid")} | \ + {"installed:" + it["tid"].upper() for it in items if it.get("tid")} for p in list(ents): if p not in seen: ents.pop(p, None) @@ -3241,7 +4539,7 @@ def library_scan(refresh=False): for it in lst: it["dup"] = [o["path"] for o in lst if o is not it] items.sort(key=lambda it: ((it.get("title") or it["name"]).lower(), it["path"])) - return {"items": items, "bfpilot": alive, "t": time.time()} + return {"items": items, "bfpilot": alive, "t": time.time(), "mounts": len(mounts)} def library_start(refresh=False): @@ -3283,7 +4581,7 @@ def cover_png(path=None, tid=None): return fh.read() return None path = fm_clean(path) - if not (path.startswith("/data/") or path.startswith("/mnt/")): + if not (path.startswith("/data/") or path.startswith("/mnt/") or path.startswith("/user/appmeta/")): return None c = lib_load()["entries"].get(path) or {} key = hashlib.sha1(("%s|%s" % (path, c.get("mtime"))).encode("utf-8")).hexdigest()[:20] @@ -3291,7 +4589,8 @@ def cover_png(path=None, tid=None): if os.path.isfile(fp): with open(fp, "rb") as fh: return fh.read() - data = E.Ftp(cfg_snapshot()).call(lambda f: F.ftp_read(f, path + "/sce_sys/icon0.png", 0, 8 * 1024 * 1024)) + icon = path + ("/icon0.png" if path.startswith("/user/appmeta/") else "/sce_sys/icon0.png") + data = E.Ftp(cfg_snapshot()).call(lambda f: F.ftp_read(f, icon, 0, 8 * 1024 * 1024)) if not data.startswith(b"\x89PNG"): return None os.makedirs(COVER_DIR, exist_ok=True) @@ -3348,11 +4647,276 @@ def sched_set(b): return sched_state() +# --------------------------------------------------------------------------- +# Copia de seguridad de la consola en el PC (partidas, configuracion y base de datos de aplicaciones) +# --------------------------------------------------------------------------- + +BK = {"busy": False, "what": "", "done": 0, "total": 0, "files": 0, "current": "", "error": "", "last": "", "t": 0} +BK_CANCEL = threading.Event() +BK_CONFIG_DIRS = ["/data/shadowmount", "/data/shadowmountplus", "/data/pldmgr", "/data/etaHEN", "/data/GameUpdater", + "/data/ps5_autoloader", "/data/kstuff"] +BK_APPDB = ["/system_data/priv/mms/app.db", "/system_data/priv/mms/appinfo.db", "/system_data/priv/mms/addcont.db"] +BK_SAVE_DIRS = ("savedata_prospero", "savedata") +BK_MAX_FILE = 200 * 1024 * 1024 # en la configuracion no se copian archivos enormes + + +def backup_dir(cfg=None): + d = ((cfg or cfg_snapshot()).get("backup_dir") or "").strip().strip('"') + return d or os.path.join(os.path.expanduser("~"), "Documents", "PS5 Directo", "Copias") + + +def _ftp_walk(f, root, out, cancel, limit=None): + """[(ruta, tamano)] de todos los archivos bajo root (vacio si no existe).""" + try: + listing = E.ftp_listdir(f, root) + except ftplib.all_errors: + return out + for n, v in listing: + if cancel.is_set(): + raise E.Cancelled() + p = root.rstrip("/") + "/" + n + if v["type"] == "dir": + _ftp_walk(f, p, out, cancel, limit) + elif v["type"] == "file" and (limit is None or (v.get("size") or 0) <= limit): + out.append((p, v.get("size") or 0)) + return out + + +def _save_roots(f): + """Carpetas de partidas de cada usuario de la consola.""" + roots = [] + try: + users = [n for n, v in E.ftp_listdir(f, "/user/home") if v["type"] == "dir"] + except ftplib.all_errors: + return roots + for u in users: + try: + sub = {n for n, v in E.ftp_listdir(f, "/user/home/" + u) if v["type"] == "dir"} + except ftplib.all_errors: + continue + for d in BK_SAVE_DIRS: + if d in sub: + roots.append("/user/home/%s/%s" % (u, d)) + return roots + + +def backup_run(sections=None, label="copia"): + """Hace la copia en un .zip con fecha. sections: saves, config, appdb, list.""" + import zipfile + cfg = cfg_snapshot() + if sections is None: + sections = [k for k in ("saves", "config", "appdb") if cfg.get("backup_" + k, True)] + ["list"] + BK_CANCEL.clear() + with LOCK: + BK.update(busy=True, what="backup", done=0, total=0, files=0, current="Buscando qué copiar…", error="") + out_dir = backup_dir(cfg) + os.makedirs(out_dir, exist_ok=True) + name = "PS5-%s-%s.zip" % (label, time.strftime("%Y-%m-%d_%H%M")) + final = os.path.join(out_dir, name) + tmp = final + ".part" + try: + f = E.Ftp(cfg).connect() + try: + todo = [] # (seccion, ruta, tamano) + if "saves" in sections: + for r in _save_roots(f): + todo += [("saves", p, s) for p, s in _ftp_walk(f, r, [], BK_CANCEL)] + if "config" in sections: + for r in BK_CONFIG_DIRS: + todo += [("config", p, s) for p, s in _ftp_walk(f, r, [], BK_CANCEL, BK_MAX_FILE)] + if "appdb" in sections: + for p in BK_APPDB: + try: + sz = f.size(p) + todo.append(("appdb", p, sz or 0)) + except ftplib.all_errors: + pass + with LOCK: + BK.update(total=sum(x[2] for x in todo), files=len(todo)) + manifest = {"app": "PS5 Directo", "version": VERSION, "date": time.strftime("%Y-%m-%d %H:%M:%S"), + "ps5_ip": cfg.get("ps5_ip"), "console_fw": cfg.get("console_fw"), "sections": sections, + "files": []} + done = 0 + with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as z: + for sec, p, sz in todo: + if BK_CANCEL.is_set(): + raise E.Cancelled() + with LOCK: + BK["current"] = p + arc = "consola" + p + try: + with z.open(zipfile.ZipInfo(arc, time.localtime()[:6]), "w", force_zip64=True) as dst: + f.retrbinary("RETR " + p, dst.write, 1024 * 1024) + manifest["files"].append({"section": sec, "path": p, "size": sz}) + except ftplib.all_errors as e: + manifest.setdefault("skipped", []).append({"path": p, "error": str(e)[:200]}) + try: + f.close() + except Exception: + pass + f = E.Ftp(cfg).connect() + done += sz + with LOCK: + BK["done"] = done + if "list" in sections: + items = (lib_load().get("entries") or {}) + titles = lib_load().get("titles") or {} + z.writestr("lista_de_la_consola.json", json.dumps( + {"titles": titles, "paths": sorted(k for k in items if not k.startswith(("mount:", "appmeta:")))}, + ensure_ascii=False, indent=1)) + z.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=1)) + finally: + try: + f.quit() + except Exception: + pass + os.replace(tmp, final) + keep = int(cfg.get("backup_keep") or 10) + pre = sorted(x for x in os.listdir(out_dir) if x.startswith("PS5-antes-de-restaurar-") and x.endswith(".zip")) + olds = sorted(x for x in os.listdir(out_dir) if x.startswith("PS5-copia-") and x.endswith(".zip")) + for x in (olds[:-keep] if keep > 0 else []) + pre[:-5]: + try: + os.remove(os.path.join(out_dir, x)) + except OSError: + pass + with LOCK: + SETTINGS["backup_last"] = time.time() + BK.update(last=final) + save_settings() + nf = len(manifest["files"]) + notify("ok", "Copia de seguridad hecha", "%d archivos (%s) en %s" % (nf, human(done), final), {"tab": "Backup"}) + return final + except E.Cancelled: + with LOCK: + BK["error"] = "Cancelada" + except Exception as e: + traceback.print_exc() + with LOCK: + BK["error"] = str(e) + notify("err", "No se pudo hacer la copia de seguridad", str(e)) + finally: + try: + if os.path.exists(tmp): + os.remove(tmp) + except OSError: + pass + with LOCK: + BK.update(busy=False, current="", t=time.time()) + return None + + +def backup_list(): + d = backup_dir() + out = [] + if os.path.isdir(d): + import zipfile + for x in sorted(os.listdir(d), key=lambda n: os.path.getmtime(os.path.join(d, n)), reverse=True): + if not x.endswith(".zip") or not x.startswith("PS5-"): + continue + fp = os.path.join(d, x) + info = {"name": x, "path": fp, "size": os.path.getsize(fp), "t": os.path.getmtime(fp), "saves": [], + "names": {}} + try: + with zipfile.ZipFile(fp) as z: + m = json.loads(z.read("manifest.json")) + info["sections"] = m.get("sections") + ids = set() + for it in m.get("files") or []: + if it.get("section") == "saves": + parts = it["path"].split("/") + if len(parts) > 5: + ids.add(parts[5]) + info["saves"] = sorted(ids) + titles = lib_load().get("titles") or {} + info["names"] = {i: (titles.get(i.upper()) or {}).get("title") for i in ids} + info["files"] = len(m.get("files") or []) + except Exception as e: + info["error"] = str(e) + out.append(info) + return out + + +def backup_restore(path, ids): + """Vuelve a poner en la consola las partidas elegidas de una copia (antes guarda las de ahora).""" + import zipfile + cfg = cfg_snapshot() + path = os.path.abspath(path) + if not path.startswith(os.path.abspath(backup_dir(cfg))) or not os.path.isfile(path): + raise E.PipeError("Esa copia no está en la carpeta de copias") + ids = set(ids or []) + with LOCK: + BK.update(busy=True, what="restore", done=0, total=0, files=0, current="Guardando antes las partidas de ahora…", + error="") + BK_CANCEL.clear() + try: + backup_run(["saves"], "antes-de-restaurar") + with LOCK: + BK.update(busy=True, what="restore", current="Restaurando…", error="") + with zipfile.ZipFile(path) as z: + m = json.loads(z.read("manifest.json")) + files = [it for it in m.get("files") or [] if it.get("section") == "saves" + and (not ids or (len(it["path"].split("/")) > 5 and it["path"].split("/")[5] in ids))] + with LOCK: + BK.update(total=sum(it.get("size") or 0 for it in files), files=len(files)) + f = E.Ftp(cfg).connect() + done = 0 + try: + for it in files: + if BK_CANCEL.is_set(): + raise E.Cancelled() + p = it["path"] + user_dir = "/".join(p.split("/")[:4]) + try: + E.ftp_listdir(f, user_dir) + except ftplib.all_errors: + raise E.PipeError("En la consola no existe %s: esta copia es de otro usuario o de otra " + "consola" % user_dir) + with LOCK: + BK["current"] = p + E.Ftp.mkdirs(f, posixpath.dirname(p)) + with z.open("consola" + p) as src: + f.storbinary("STOR " + p, src, 1024 * 1024) + done += it.get("size") or 0 + with LOCK: + BK["done"] = done + finally: + try: + f.quit() + except Exception: + pass + notify("ok", "Partidas restauradas", "%d archivos desde %s" % (len(files), os.path.basename(path)), + {"tab": "Backup"}) + except E.Cancelled: + with LOCK: + BK["error"] = "Cancelado" + except Exception as e: + with LOCK: + BK["error"] = str(e) + notify("err", "No se pudieron restaurar las partidas", str(e)) + finally: + with LOCK: + BK.update(busy=False, current="", t=time.time()) + + def monitor(): """Cada pocos segundos: que el PC no se duerma con trabajo y la accion al acabar la cola.""" awake = None + last_auto = 0.0 while True: time.sleep(5) + try: + # copia de seguridad diaria (si esta activada y la consola responde) + if (SETTINGS.get("backup_auto") and not BK["busy"] and not busy() + and time.time() - float(SETTINGS.get("backup_last") or 0) > 24 * 3600 + and time.time() - last_auto > 1800): + last_auto = time.time() + try: + E.Ftp(cfg_snapshot()).call(lambda f: f.voidcmd("NOOP")) + threading.Thread(target=backup_run, daemon=True).start() + except Exception: + pass # consola apagada: se intentara mas tarde + except Exception: + traceback.print_exc() try: b = busy() with LOCK: @@ -3451,7 +5015,7 @@ def payload_send(pid=None, path=None): LAN = {"srv": None, "port": None, "error": ""} LAN_HITS = [] # lo ultimo que ha pedido el movil: para saber si llega y si se corta -LAN_DENY = {"/api/lan/firewall", "/api/lan/hits", "/api/update/zip", "/api/update/github", "/api/quit", "/api/pick", "/api/open_ext_folder", +LAN_DENY = {"/api/bk/restore", "/api/bk/open", "/api/report/open", "/api/col/set", "/api/lan/firewall", "/api/lan/hits", "/api/update/zip", "/api/update/github", "/api/quit", "/api/pick", "/api/open_ext_folder", "/api/fm/open_local", "/api/lan", "/api/forge/download", "/api/payload/file", "/api/fixperms"} LAN_LOCKED = {"sevenzip_path", "forge_path", "pc_temp_dir", "update_repo", "lan_access", "lan_port", "lan_token"} @@ -3750,6 +5314,75 @@ def _speedtest_thread(url): SPEEDTEST["running"] = False +def net_mismatch(cfg): + """Si el PC y la PS5 parecen estar en redes distintas (p. ej. 192.168.50.x y 192.168.1.x), lo explica.""" + host = E.clean_host(cfg.get("ps5_ip") or "")[0] + if not re.match(r"^\d+\.\d+\.\d+\.\d+$", host or ""): + return "" + pc = F.lan_ip(host) + if not pc or pc.rsplit(".", 1)[0] == host.rsplit(".", 1)[0]: + return "" + return ("Tu PC está en la red %s.x y la PS5 en la %s.x: parecen redes distintas (dos routers, la Wi-Fi de " + "invitados o una VPN). Conecta los dos al mismo router o a la misma Wi-Fi; si cambia la IP de la PS5, " + "ponla otra vez en Ajustes." % (pc.rsplit(".", 1)[0], host.rsplit(".", 1)[0])) + + +SLOW_MIN = 2 * 1024 * 1024 # por debajo de 2 MB/s no se vigila (no hay caida que avisar) + + +def speed_watch(): + """Avisa si la velocidad hacia la PS5 cae de golpe y no se recupera. Lo tipico: la PS5 lleva un rato sin + que nadie toque el mando y baja el ritmo; al usar su menu vuelve a ir rapida.""" + hist = {} + while True: + time.sleep(5) + now = time.time() + try: + with LOCK: + live = [(j["id"], float(j.get("speed") or 0), j.get("title") or "") for j in JOBS + if j.get("status") == "enviando" and j.get("pc_step") not in ("bajar", "convertir")] + ids = set() + for jid, sp, title in live: + ids.add(jid) + h = hist.setdefault(jid, {"s": [], "alert": False}) + h["s"].append((now, sp)) + h["s"] = [x for x in h["s"] if now - x[0] <= 300] + old = sorted(v for t, v in h["s"] if 30 <= now - t <= 300) + new = [v for t, v in h["s"] if now - t < 25] + if len(old) < 6 or len(new) < 4: + continue + before = old[int(len(old) * 0.75)] # lo que solia ir (sin picos) + after = sum(new) / len(new) + job = find(jid) + if job is None: + continue + if not h["alert"] and before >= SLOW_MIN and max(new) < before * 0.3: + h["alert"] = True + mb = lambda v: "%.1f" % (v / 1048576) + with LOCK: + job["slow"] = {"from": mb(before), "to": mb(after), "t": now} + jlog(job, "La velocidad a la PS5 ha bajado de %s a %s MB/s" % (mb(before), mb(after))) + notify("warn", "La velocidad ha bajado mucho: %s" % title, + "Ha pasado de %s a %s MB/s. Si la PS5 lleva un rato sin que nadie toque el mando, pulsa " + "un botón: cuando está inactiva va más lenta. Si no, puede ser el Wi-Fi o el servidor de " + "descarga." % (mb(before), mb(after)), {"tab": "Transfers", "job": jid}, + key="slow:%s:%d" % (jid, int(now))) + elif h["alert"] and after > before * 0.6: + h["alert"] = False + with LOCK: + job.pop("slow", None) + jlog(job, "La velocidad ha vuelto a subir") + for jid in list(hist): + if jid not in ids: + hist.pop(jid, None) + j = find(jid) + if j is not None: + with LOCK: + j.pop("slow", None) + except Exception: + traceback.print_exc() + + def health_check(): """Revision completa: que funciona, que no y que hacer.""" cfg = cfg_snapshot() @@ -3778,6 +5411,7 @@ def health_check(): add("Escribir en la PS5", False, str(e), "El FTP no deja escribir: prueba a recargar el payload FTP") except Exception as e: add("FTP de la PS5", False, str(e), + net_mismatch(cfg) or "Comprueba la IP en Ajustes y que el payload FTP este cargado (puerto %s)" % cfg["ftp_port"]) finally: try: @@ -4004,7 +5638,7 @@ def github_latest(repo): if zips: url = zips[0].get("browser_download_url") url = url or j.get("zipball_url") - return (j.get("tag_name") or "").lstrip("vV"), url, (j.get("body") or "")[:2000] + return (j.get("tag_name") or "").lstrip("vV"), url, (j.get("body") or "")[:12000] # --------------------------------------------------------------------------- @@ -4106,6 +5740,55 @@ class Handler(BaseHTTPRequestHandler): return self._send(200, PRUEBA_HTML, "text/html; charset=utf-8") if self.path == "/api/ext/ping": return self._send(200, {"ok": True, "app": "PS5 Directo", "version": VERSION}) + if self.path.startswith("/api/media/file?"): + q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + if not token_ok((q.get("t") or [""])[0]): + return self._send(403, {"ok": False}) + try: + fp, ctype = media_cached((q.get("p") or [""])[0]) + except Exception as e: + return self._send(404, {"ok": False, "error": str(e)}) + size = os.path.getsize(fp) + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(size)) + self.send_header("Cache-Control", "private, max-age=86400") + if (q.get("dl") or [""])[0]: + self.send_header("Content-Disposition", 'attachment; filename="%s"' % os.path.basename(fp)) + self.end_headers() + with open(fp, "rb") as fh: + shutil_copy = __import__("shutil").copyfileobj + shutil_copy(fh, self.wfile, 1024 * 1024) + return + if self.path.startswith("/api/report/file?"): + q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + if not token_ok((q.get("t") or [""])[0]): + return self._send(403, {"ok": False}) + name = os.path.basename((q.get("n") or [""])[0]) + fp = os.path.join(report_dir(), name) + if not name.endswith(".zip") or not os.path.isfile(fp): + return self._send(404, {"ok": False}) + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(os.path.getsize(fp))) + self.send_header("Content-Disposition", 'attachment; filename="%s"' % name) + self.end_headers() + with open(fp, "rb") as fh: + self.wfile.write(fh.read()) + return + if self.path.startswith("/api/colicon?"): + q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + if not token_ok((q.get("t") or [""])[0]): + return self._send(403, {"ok": False}) + data = col_icon((q.get("p") or [""])[0]) + if not data: + return self._send(404, {"ok": False}) + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return if self.path.startswith("/api/cover?"): q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) if not token_ok((q.get("t") or [""])[0]): @@ -4190,7 +5873,7 @@ class Handler(BaseHTTPRequestHandler): b = {k: v for k, v in b.items() if k not in LAN_LOCKED} if p == "/api/add": ls = [x.strip() for x in str(b.get("lines", "")).splitlines() if x.strip()] - if any(not re.match(r"^(https?|ftp)://", x, re.I) for x in ls): + if any(not re.match(r"^((https?|ftp)://|magnet:\?)", x, re.I) for x in ls): return self._send(400, {"ok": False, "error": "Desde el móvil solo se pueden añadir enlaces " "(http, https o ftp), no archivos del PC"}) extra = extra_api(self, p, b) @@ -4284,7 +5967,7 @@ class Handler(BaseHTTPRequestHandler): except Exception: bases = ["/data/homebrew"] return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]}) - if p.startswith("/api/fm/") or p.startswith("/api/space/") or p.startswith("/api/notifs") or p in ("/api/pick", "/api/history", "/api/history/clear"): + if p.startswith("/api/fm/") or p.startswith("/api/lib/") or p.startswith("/api/space/") or p.startswith("/api/notifs") or p.startswith("/api/gu/") or p.startswith("/api/bk/") or p.startswith("/api/col/") or p.startswith("/api/media/") or p.startswith("/api/report") or p in ("/api/pick", "/api/history", "/api/history/clear", "/api/history/redo"): try: return self._send(200, dict(ok=True, **fm_api(p, b))) except E.PipeError as e: @@ -4323,7 +6006,14 @@ class Handler(BaseHTTPRequestHandler): if p == "/api/speedtest/result": return self._send(200, {"ok": True, "running": SPEEDTEST["running"], "result": SPEEDTEST["result"]}) if p == "/api/test": - return self._send(200, {"ok": True, **test_connection(cfg_snapshot())}) + cfg = cfg_snapshot() + r = dict(test_connection(cfg)) + if r.get("ftp") != "ok": + warn = net_mismatch(cfg) + if warn: + r["net_warn"] = warn + notify("warn", "El PC y la PS5 están en redes distintas", warn, key="net:" + warn[:60]) + return self._send(200, {"ok": True, **r}) if p == "/api/add": lines = [x.strip() for x in str(b.get("lines", "")).splitlines() if x.strip()] if not lines: @@ -4363,6 +6053,7 @@ class Handler(BaseHTTPRequestHandler): job["phase"] = "Cancelado" elif action == "decide": choice = b.get("choice") + at_place = (job.get("ask") or {}).get("at") == "place" job.pop("ask", None) if choice == "cancel": job["status"] = "cancelado" @@ -4370,9 +6061,14 @@ class Handler(BaseHTTPRequestHandler): elif choice in ("send", "replace") and job["status"] in ("pausado", "error"): job["dup_choice"] = choice job["error"] = "" - job["status"] = "pendiente" - job["phase"] = "En cola" CANCELS.setdefault(job_id, threading.Event()).clear() + if at_place and job.get("info"): + job["status"] = "en cola para descomprimir" + job["phase"] = "Colocando" + EXTRACT_Q.append(job_id) + else: + job["status"] = "pendiente" + job["phase"] = "En cola" WAKE.notify_all() elif action == "retry": if job["status"] in ("error", "cancelado", "pausado"): @@ -4394,6 +6090,18 @@ class Handler(BaseHTTPRequestHandler): elif action == "remove": if job["status"] in ("hecho", "error", "cancelado", "pausado"): JOBS.remove(job) + torrent_forget(job) + elif action == "restart": + # "Descargar de nuevo": como si se volviera a pegar el enlace (trabajo nuevo, desde cero) + if job["status"] in ("hecho", "error", "cancelado"): + torrent_forget(job) + nj = new_job(list(job.get("orig_lines") or job.get("lines") or []), job.get("password") or "", job["dest"], + where=job.get("where"), raw=job.get("raw"), fmt=job.get("fmt"), + comp=job.get("comp")) + nj["fmt_set"] = job.get("fmt_set", False) + nj["title"] = job.get("title") or "" + JOBS[JOBS.index(job)] = nj + WAKE.notify_all() elif action in ("up", "down"): i = JOBS.index(job) k = i - 1 if action == "up" else i + 1 @@ -4891,7 +6599,7 @@ def main(): with WAKE: JOBS.append(job) save_jobs() - for target in (transfer_worker, extract_worker, monitor): + for target in (transfer_worker, extract_worker, monitor, speed_watch): threading.Thread(target=target, daemon=True).start() port = pick_port() srv = Server(("127.0.0.1", port), Handler) diff --git a/bg.js b/bg.js deleted file mode 100644 index 66b6e7e..0000000 --- a/bg.js +++ /dev/null @@ -1,84 +0,0 @@ -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. -// Si PS5 Directo no esta abierto, no toca nada: la descarga sigue normal. - -const WANTED_FILE = /\.(part\d+\.rar|rar|r\d{2,3}|7z(\.\d{3})?|zip|z\d{2}|\d{3}|pkg|exfat|ffpkg|ffpfsc?|ufs)$/i; -const PORTS = [8777, 8778, 8779]; - -async function settings() { - return chrome.storage.local.get({ enabled: true, captured: 0 }); -} - -async function findApp() { - for (const port of PORTS) { - try { - const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" }); - if (r.ok) { - const j = await r.json(); - if (j.app === "PS5 Directo") return { port, version: j.version }; - } - } catch (e) { /* no esta en ese puerto */ } - } - return null; -} - -function fileName(item) { - const f = (item.filename || "").split(/[\\/]/).pop(); - if (f) return f; - try { return decodeURIComponent(new URL(item.finalUrl || item.url).pathname.split("/").pop()); } - catch (e) { return ""; } -} - -async function handle(item) { - const cfg = await settings(); - if (!cfg.enabled) return; - const url = item.finalUrl || item.url; - if (!/^https?:/i.test(url)) return; - const name = fileName(item); - const path = url.split("?")[0]; - if (!WANTED_FILE.test(name) && !WANTED_FILE.test(path)) return; // no es un archivo que nos interese - const app = await findApp(); - if (!app) { // PS5 Directo cerrado: no tocar - chrome.action.setBadgeText({ text: "off" }); - return; - } - // Como los gestores de descargas: pasamos las cookies de ese alojamiento (sesion, cuenta - // premium), el navegador y la pagina de origen, para que el servidor nos trate igual. - // Solo van a PS5 Directo en este PC (127.0.0.1), a ningun otro sitio. - const cookies = {}; - for (const u of [url, item.url]) { - try { - const host = new URL(u).host.toLowerCase(); - if (host in cookies) continue; - const list = await chrome.cookies.getAll({ url: u }); - cookies[host] = list.map(c => `${c.name}=${c.value}`).join("; "); - } catch (e) { /* sin permiso o enlace raro: sin cookies */ } - } - try { - const r = await fetch(`http://127.0.0.1:${app.port}/api/ext/capture`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, original: item.url, name, cookies, - ua: navigator.userAgent, referrer: item.referrer || "" }) - }); - if (!r.ok) return; - } catch (e) { return; } - try { await chrome.downloads.cancel(item.id); } catch (e) {} - try { await chrome.downloads.erase({ id: item.id }); } catch (e) {} - const n = (cfg.captured || 0) + 1; - await chrome.storage.local.set({ captured: n, last: name }); - chrome.action.setBadgeBackgroundColor({ color: "#2563eb" }); - chrome.action.setBadgeText({ text: String(n % 1000) }); - chrome.notifications.create({ - 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.") - }); -} - -// Cuando ya se sabe el nombre del archivo y el enlace final (tras las redirecciones) -chrome.downloads.onDeterminingFilename.addListener((item, suggest) => { - suggest(); // no cambiamos el nombre - handle(item); -}); diff --git a/demo_en.gif b/demo_en.gif deleted file mode 100644 index 6a3586d..0000000 Binary files a/demo_en.gif and /dev/null differ diff --git a/demo_es.gif b/demo_es.gif deleted file mode 100644 index d6c32a3..0000000 Binary files a/demo_es.gif and /dev/null differ diff --git a/docs/demo_en.gif b/docs/demo_en.gif new file mode 100644 index 0000000..1d7412a Binary files /dev/null and b/docs/demo_en.gif differ diff --git a/docs/demo_es.gif b/docs/demo_es.gif new file mode 100644 index 0000000..b8b4ff6 Binary files /dev/null and b/docs/demo_es.gif differ diff --git a/features.py b/features.py index 7922574..0a2f469 100644 --- a/features.py +++ b/features.py @@ -547,9 +547,32 @@ def ftp_read(f, path, offset=0, n=256 * 1024): f.voidresp() except ftplib.all_errors: pass + if len(buf) >= n: + _ftp_resync(f) return bytes(buf) +def _ftp_resync(f): + """Al cortar una descarga a medias algunos servidores mandan una respuesta de mas (426 y luego 226), + y la siguiente orden recibiria la respuesta equivocada. Manda un NOOP y lee hasta su 200.""" + old = None + try: + old = f.sock.gettimeout() + f.sock.settimeout(5) + f.putcmd("NOOP") + for _ in range(4): + line = f.getmultiline() + if line.startswith("200"): + break + except Exception: + pass + finally: + try: + f.sock.settimeout(old) + except Exception: + pass + + def title_from_param(text): try: j = json.loads(text) if isinstance(text, str) else text @@ -585,6 +608,40 @@ def param_facts(text): } +def parse_sfo(data): + """param.sfo (PS4): diccionario con sus claves (TITLE, TITLE_ID, APP_VER, SYSTEM_VER, CATEGORY...).""" + import struct + out = {} + if len(data) < 20 or data[:4] != b"\0PSF": + return out + _, _, key_off, data_off, n = struct.unpack_from("<4sIIII", data, 0) + for i in range(min(n, 256)): + e = 20 + i * 16 + if e + 16 > len(data): + break + k_off, fmt, ln, _mx, d_off = struct.unpack_from("<HHIII", data, e) + ks = key_off + k_off + key = data[ks:data.find(b"\0", ks)].decode("ascii", "replace") + raw = data[data_off + d_off:data_off + d_off + ln] + if fmt == 0x0404 and len(raw) >= 4: + out[key] = struct.unpack_from("<I", raw)[0] + else: + out[key] = raw.split(b"\0", 1)[0].decode("utf-8", "replace") + return out + + +def sfo_facts(data): + s = parse_sfo(data) + sv = s.get("SYSTEM_VER") + fw = None + if isinstance(sv, int) and sv: + fw = "%x.%02x" % (sv >> 24, (sv >> 16) & 0xFF) + cat = str(s.get("CATEGORY") or "") + return {"title": s.get("TITLE") or None, "tid": s.get("TITLE_ID") or None, + "version": s.get("APP_VER") or s.get("VERSION") or None, "fw_param": fw, "need_fw": fw, + "extra": cat in ("ac", "gp", "sd")} + + def inspect_remote_game(f, path, listing=None): """Datos de una aplicacion en carpeta en la consola, por FTP. listing: entradas de 'path'.""" names = {n: x for n, x in (listing if listing is not None else E.ftp_listdir(f, path))} @@ -597,7 +654,11 @@ def inspect_remote_game(f, path, listing=None): out["icon"] = "icon0.png" in sce if "param.json" in sce: raw = ftp_read(f, path + "/sce_sys/param.json", 0, 512 * 1024) - out.update(param_facts(raw.decode("utf-8", "replace"))) + out.update(param_facts(raw.decode("utf-8-sig", "replace"))) + if not out.get("tid"): + out["param_err"] = "param.json: %d bytes leídos (%r)" % (len(raw), raw[:60]) + elif sce: + out["param_err"] = "sce_sys no tiene param.json" if out["has_eboot"]: try: out["fw_exe"] = exe_firmware(lambda off, n: ftp_read(f, path + "/eboot.bin", off, n)) @@ -763,7 +824,25 @@ def forge_cli_name(): return "ps5-dump-forge.exe" if os.name == "nt" else "ps5-dump-forge" -def find_forge(custom, data_dir): +FORGE_NEEDED_FLAGS = ("--to", "--lz4-pack", "--lz4-unpack", "--kraken", "--level") + + +def forge_cli_ok(exe): + """Comprueba que una version de Forge sigue entendiendo las opciones que usa PS5 Directo. + True = vale, False = le faltan opciones, None = no se ha podido saber.""" + import subprocess + try: + p = subprocess.run([exe, "convert", "--help"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=60, creationflags=E._NOWIN) + except Exception: + return None + out = p.stdout.decode("utf-8", "replace") + if "--to" not in out: + return None + return all(f in out for f in FORGE_NEEDED_FLAGS) + + +def find_forge(custom, data_dir, skip=()): custom = (custom or "").strip().strip('"') if custom and os.path.isfile(custom): return custom @@ -771,6 +850,8 @@ def find_forge(custom, data_dir): best = None if os.path.isdir(root): for d in os.listdir(root): + if d in skip: + continue p = os.path.join(root, d, forge_cli_name()) if os.path.isfile(p) and (best is None or os.path.getmtime(p) > os.path.getmtime(best)): best = p @@ -794,8 +875,8 @@ def _forge_asset(assets): return None -def download_forge(data_dir, log=None, progress=None): - tag, assets = github_release(FORGE_REPO) +def download_forge(data_dir, log=None, progress=None, release=None): + tag, assets = release or github_release(FORGE_REPO) a = _forge_asset(assets) if not a: raise E.PipeError("La última versión de PS5 Dump Forge no trae descarga para este sistema") diff --git a/icon128.png b/icon128.png deleted file mode 100644 index 85c4fb8..0000000 Binary files a/icon128.png and /dev/null differ diff --git a/icon16.png b/icon16.png deleted file mode 100644 index b6a8ca2..0000000 Binary files a/icon16.png and /dev/null differ diff --git a/icon32.png b/icon32.png deleted file mode 100644 index ef53dac..0000000 Binary files a/icon32.png and /dev/null differ diff --git a/icon48.png b/icon48.png deleted file mode 100644 index 0a3ac25..0000000 Binary files a/icon48.png and /dev/null differ diff --git a/index.html b/index.html deleted file mode 100644 index 18c4c17..0000000 --- a/index.html +++ /dev/null @@ -1,2369 +0,0 @@ -<!doctype html> -<html lang="es"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>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. -
-
Vale cualquiera de estos:Carpeta.zip.7z.rar.exfat.ffpkg.ffpfs.ffpfsc.fpkgLZ4y se puede pasar de uno a otro abajo.
-
-
- - -
-
- - - -
-
- - -
- -
-
-
Opcional. Si no sabes cuál elegir, deja «Sin convertir».
-
Ocupan menos
Una imagen, velocidad completa
Sin imagen
Para instalar
- - -
-
- - 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). -
-
- -
-
-

Cola

-
- - -
-
-
-
-
- -
-
-

Aplicaciones de la consola

- -
-

Lo que hay en /data/homebrew (y en los USB): portada, versión, si es carpeta o imagen y qué firmware pide. Con el firmware de tu PS5 en Ajustes te marca los que no arrancarán. Funciona solo con FTP; con BFpilot también ve cuánto ocupa cada carpeta.

-
- - - -
- -
-
-
-
- -
-
-
-

Archivos de la consola

-
-
-
-
- -
-
- - - -
- - - -
-
- - - - - - - - - - - - -
- - -
- - - - - - - - - - -
NombreTamañoModificadoPermisos
-
-

-

Atajos: doble clic abre · clic derecho para el menú · Supr borra · F2 renombra · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selecciona todo · Retroceso sube

-
-
-
-
- -
-
-

Espacio

-
-
-

Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de contenido repetido (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.

- -
-
-
-

Hacer sitio

-

Di cuánto necesitas liberar y te propongo qué borrar: primero restos y envíos a medias, luego copias repetidas de lo mismo y luego instaladores .pkg. Las aplicaciones solo salen como opción, sin marcar. Nada se borra sin que lo confirmes.

-
GB -
-
-
- -
- -
-
-

Actividad

-

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

- -
FechaNombreResultadoTamañoDuraciónDestino
-
-
- - - -

Mover a…

-
-
-
-
-
-
-

-
-
-
- -

Ya existe

-
-

-
    -
    - - - - -
    -

    Reemplazar sobrescribe los archivos con el mismo nombre. Si es una carpeta, se combina: lo que ya tenía y no estás pegando se queda. Conservar ambos le pone (2) al nuevo.

    -
    -
    - -

    Extraer

    -
    -

    La consola lo descomprime en una carpeta al lado del archivo (con BFpilot) y deja el comprimido como está. Nada pasa por el PC.

    - - -

    -
    -
    -
    - - -

    Novedades

    -
    -
    -
    - - -

    Test de velocidad

    -
    -

    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.

    -
    -
    -
    - - -

    Primeros pasos

    -
    -
    -
    - -

    ¿Qué formato elijo?

    -
    -
    -
    ¿No lo tienes claro?Sin convertir o Imagen .ffpkg
    -
    ¿Te falta espacio?Imagen comprimida .ffpfsc
    -
    ¿Firmware 11.60 o inferior y lo quieres instalado?Paquete .fpkg
    -
    -
    FormatoEspacio en la PS5VelocidadVentajasInconvenientes
    Imagen comprimida .ffpfsc
    Ideal si te falta espacio y el juego no es muy exigente
    Espacio en la PS5: 30-60 % menosVelocidad: lenta (150-250 MB/s)Ahorra mucho espacio en la consola. Vale para cualquier título.Lee a un tercio de lo que lee un USB y a una décima del disco interno: los títulos que cargan mucho pueden dar tirones. Experimental.
    LZ4 / LZ4 en imagen
    Para probar con títulos AMPR si quieres ahorrar espacio
    Espacio en la PS5: menos (depende del juego)Velocidad: ? depende del juegoComprime solo lo que el juego lee, para que ocupe menos sin perder tanta velocidad como .ffpfsc.Solo títulos AMPR, y algunos ni así (por ejemplo HITMAN 3). Experimental: con algunos se cuelga al guardar partida y desde el disco interno puede dar tirones. No hay pruebas publicadas de cuánto más rápido carga.
    Imagen .ffpkg
    La mejor opción para la mayoría
    Espacio en la PS5: igualVelocidad: rápida (como un disco USB)La que recomienda ShadowMountPlus. Un solo archivo: envío más rápido, biblioteca ordenada y sin problemas de permisos.Hay que convertirla en el PC; los archivos de dentro no se tocan sueltos. Algo más lenta que una carpeta en el disco interno.
    Imagen .exfat
    Si una herramienta o un título te la pide
    Espacio en la PS5: igualVelocidad: rápida (como un disco USB)Se comporta como un disco externo. Algunas herramientas la prefieren para títulos AMPR/LZ4.ShadowMountPlus aconseja usarla solo si hace falta; si no, mejor .ffpkg.
    Imagen .ffpfs
    Para probar
    Espacio en la PS5: igualVelocidad: ? sin medir aúnUsa el sistema de archivos de la propia PS5 (PFS).Experimental en ShadowMountPlus; nadie ha publicado cuánto rinde.
    Sin convertir
    Si no quieres complicarte
    Espacio en la PS5: igualVelocidad: ? la del formato que traigaNada que esperar ni que convertir; no usa el disco del PC.Si es carpeta: miles de archivos (envío más lento) y a veces hay que arreglar permisos.
    Carpeta
    Si quieres la máxima velocidad o tocar archivos
    Espacio en la PS5: igualVelocidad: la más rápida en el disco internoSin capa de imagen: lo más rápido. Se ven y se cambian archivos sueltos; vale para cualquier cargador.Envío más lento por FTP con muchos archivos; a veces hay que arreglar permisos (CE-107750-0).
    Paquete .fpkg
    Firmware 11.60 o inferior y lo quieres como una app normal
    Espacio en la PS5: se envía comprimido; instalado ocupa lo normalVelocidad: nativaSe instala y aparece como una app normal de la consola, sin montar nada.Solo firmware 11.60 o inferior (kstuff, fpkg-enable y ppr-patch). Instalar lleva tiempo y hay que borrar el .pkg después.
    -

    Todos menos «Sin convertir» se preparan en tu PC y ocupan sitio en su disco hasta que se envían. ShadowMountPlus avisa de que montar imágenes puede dar problemas al apagar la consola, sobre todo en firmwares antiguos: ten copia de lo importante.

    -

    Las velocidades salen de la documentación de ShadowMountPlus y de las herramientas de la comunidad. Son orientativas: cada título carga distinto.

    -
    -
    - -

    Antes de convertir

    -
    -

    Con este formato, PS5 Directo prepara el contenido en tu PC antes de enviarlo, con PS5 Dump Forge. Hasta ahora todo pasaba directo a la consola sin ocupar sitio en el PC; con este formato no:

    -
      -
    • Los enlaces se descargan primero al PC.
    • -
    • La conversión necesita sitio libre en el disco del PC (más o menos lo que ocupa el contenido descomprimido).
    • -
    • Al terminar de enviarse, se borra todo del PC.
    • -
    -

    -

    - -
    -
    -
    - -

    Cargar payloads en la PS5

    -
    -

    Manda el payload al cargador de la consola (etaHEN o elfldr, puerto 9021). Se descarga la última versión de GitHub y se guarda para la próxima vez. El cargador de Y2JB Autoloader solo acepta la propia consola: con él usa Payload Manager.

    -
    -
    -
    -
    - -

    ¿Por qué va lento?

    -
    -

    Revisa cómo llega el PC a la PS5: Wi-Fi o cable, banda y señal, cables a 100 Mb/s, VPN, redes distintas y el ping a la consola. Tarda unos 15 segundos. Para medir velocidades usa también el test de velocidad.

    -
    -
    -
    -
    - -

    Programar la cola

    -
    -
    -
    -
    -
    -

    Lo que ya se esté enviando sigue; lo nuevo espera a esa hora. Antes de apagar o suspender te avisa y espera 1 minuto, por si quieres cancelarlo. Mientras hay envíos, el PC no se suspende solo (se puede cambiar en Ajustes).

    -
    -
    -
    - -

    Abrir en el móvil

    -
    - -
    -
    -
    -

    Escanea el código con la cámara del móvil (conectado a la misma red). Desde el móvil puedes pegar enlaces, ver la cola, pausar, ver tu biblioteca y navegar por la consola. Por seguridad no se pueden instalar actualizaciones ni cambiar las rutas de programas. Windows puede preguntar si permites el acceso: elige «Redes privadas». Con esto activado, PS5 Directo sigue abierto aunque cierres la ventana (ciérralo con Salir).

    -
    -
    - - -

    Ajustes

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    Para incluir la descarga, usa "Test de velocidad" en una transferencia de la cola.

    -

    El límite se aplica al momento, sin parar nada. Si el espacio libre baja del margen, PS5 Directo para el envío o la descompresión antes de llenar la consola (0 = no vigilar). Con alojamientos que limitan conexiones usa solo 2 por archivo y baja más si el servidor protesta.

    -
    - - -
    - - - - - -
    - Descomprimir en el PC -

    Alternativa a BFpilot: 7-Zip descomprime en el PC sobre la marcha y la app manda los archivos ya sueltos, comprobando cada uno con su CRC. No escribe nada descomprimido en el PC y en la PS5 solo ocupa el contenido final (sin el RAR al lado). A cambio, por el Wi-Fi viaja todo sin comprimir (puede ser 3-4 veces más) y los enlaces se bajan antes al PC (hace falta sitio para el RAR).

    -
    -
    -
    -
    -
    -
    -
    - Imágenes y LZ4 (PS5 Dump Forge) -

    Con PS5 Dump Forge el contenido se prepara en el PC en el formato que elijas: imagen .ffpkg (recomendada por ShadowMountPlus), .exfat, .ffpfs o .ffpfsc (más pequeña pero más lenta de leer), paquete .fpkg (solo firmware 11.60 o inferior), carpeta normal o empaquetado en LZ4 (solo títulos AMPR, experimental: algunos se cuelgan al guardar). Se envía un único archivo, que va más rápido por FTP que miles pequeños y sin el problema de permisos. Hace falta sitio en el PC para la imagen. Se descarga solo de GitHub la primera vez.

    -
    -
    -
    -
    -

    -
    -
    - Herramientas -
    -
    -
    - - - - -
    -
    - Extensión para capturar enlaces (Chrome / Edge) -

    Con ella, al pulsar Descargar en cualquier web (después del captcha), el enlace de un .rar/.7z/.zip/.pkg va directo aquí y el navegador no descarga nada. Si PS5 Directo está cerrado, la descarga sigue normal.
    - Instalarla (una vez): 1) Abre chrome://extensions (o edge://extensions) · 2) Activa Modo de desarrollador · 3) Cargar descomprimida y elige la carpeta extension de PS5 Directo.

    - -
    -
    - Actualizar PS5 Directo -

    Elige el .zip de la versión nueva y se instala solo (guarda una copia de la versión actual en la carpeta .backup). Se reinicia y se abre una ventana nueva.

    - - -
    - -
    -
    -
    - Permisos de las aplicaciones -

    Lo que descomprimió BFpilot puede haber quedado sin permiso de ejecución y la PS5 no lo abre (error CE-107750-0). Esto pone permisos 777 a todas las aplicaciones en carpeta. No toca el contenido.

    - -
    -
    -
    - Limpieza de la PS5 -

    Busca en /data/ps5directo restos que ya no usa ninguna transferencia de la cola (partes de trabajos quitados, descompresiones a medias...). Te enseña cuánto ocupa cada cosa antes de borrar.

    - -
    -
    -
    -

    Necesitas en la PS5: el servidor FTP (puerto 2121) y BFpilot cargado desde Payload Manager (puerto 5905). Para ir más rápido, con Wi-Fi baja “Trozo” a 2–4 MB.

    -
    -
    - -
    - - - - - - - - - - - - diff --git a/logo.svg b/logo.svg deleted file mode 100644 index 7bc157e..0000000 --- a/logo.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/manifest.json b/manifest.json deleted file mode 100644 index b10ffcb..0000000 --- a/manifest.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "manifest_version": 3, - "name": "PS5 Directo - Capturar enlaces", - "version": "1.1.0", - "description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace (con la sesion del navegador) a PS5 Directo en vez de bajarlo al PC.", - "permissions": [ - "downloads", - "storage", - "notifications", - "cookies" - ], - "host_permissions": [ - "http://127.0.0.1/*", - "" - ], - "background": { - "service_worker": "bg.js" - }, - "action": { - "default_popup": "popup.html", - "default_title": "PS5 Directo", - "default_icon": { - "16": "icon16.png", - "32": "icon32.png" - } - }, - "icons": { - "16": "icon16.png", - "48": "icon48.png", - "128": "icon128.png" - } -} \ No newline at end of file diff --git a/popup.html b/popup.html deleted file mode 100644 index d6b9114..0000000 --- a/popup.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - -

    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. - - diff --git a/popup.js b/popup.js deleted file mode 100644 index 273579c..0000000 --- a/popup.js +++ /dev/null @@ -1,42 +0,0 @@ -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 { - const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" }); - if (r.ok) { const j = await r.json(); if (j.app === "PS5 Directo") return j; } - } catch (e) {} - } - return null; -} -(async () => { - const cfg = await chrome.storage.local.get({ enabled: true, captured: 0, last: "" }); - const en = document.getElementById("en"); - en.checked = cfg.enabled; - en.onchange = () => chrome.storage.local.set({ enabled: en.checked }); - document.getElementById("cnt").textContent = - 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: "" }); - document.getElementById("cnt").textContent = ""; - }; - const app = await findApp(); - const st = document.getElementById("st"); - 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/ps5directo.ico b/ps5directo.ico deleted file mode 100644 index 04a48ee..0000000 Binary files a/ps5directo.ico and /dev/null differ diff --git a/icon-192.png b/web/icon-192.png similarity index 100% rename from icon-192.png rename to web/icon-192.png diff --git a/icon-256.png b/web/icon-256.png similarity index 100% rename from icon-256.png rename to web/icon-256.png diff --git a/icon-512.png b/web/icon-512.png similarity index 100% rename from icon-512.png rename to web/icon-512.png diff --git a/web/index.html b/web/index.html index 6e2521b..be15b1a 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,10 @@ PS5 Directo - + + + +
    -

    PS5 Directo

    +

    PS5 Directo

    Envía archivos a tu consola por la red local, sin pasar por el disco del PC
    - FTP - BFpilot + FTP + BFpilot + - - + + +
    + @@ -193,13 +478,14 @@ tr:hover .rowact{opacity:1}

    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.
    +
    Vale cualquiera de estos:Carpeta.zip.7z.rar.exfat.ffpkg.ffpfs.ffpfsc.fpkgLZ4Torrenty se puede pasar de uno a otro abajo.
    @@ -217,6 +503,29 @@ tr:hover .rowact{opacity:1}
    + +
    +
    + +
    @@ -228,12 +537,58 @@ tr:hover .rowact{opacity:1}

    Cola

    +
    +
    +
    +
    +
    +

    Aplicaciones de la consola

    + +
    +

    Lo que hay en /data/homebrew (y en los USB): portada, versión, si es carpeta o imagen y qué firmware pide. Con el firmware de tu PS5 en Ajustes te marca los que no arrancarán. Funciona solo con FTP; con BFpilot también ve cuánto ocupa cada carpeta. Si a algo le falta la portada o el nombre, se buscan en lo que la PS5 guarda de cada aplicación de su menú.

    + +
    + + + +
    + +
    +
    +
    + +
    +
    @@ -241,42 +596,51 @@ tr:hover .rowact{opacity:1}
    -
    - - - - - - - +
    + +
    +
    + + + +
    + + + +
    +
    + + + + + + + + + + + + +
    + + +
    + + + + + + + + + + +
    NombreTamañoModificadoPermisos
    +
    +

    +

    Atajos: doble clic abre · clic derecho para el menú · Supr borra · F2 renombra · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selecciona todo · Retroceso sube

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

    @@ -285,11 +649,18 @@ tr:hover .rowact{opacity:1}

    Espacio

    -

    Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.

    +

    Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de contenido repetido (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.

    +
    +

    Hacer sitio

    +

    Di cuánto necesitas liberar y te propongo qué borrar: primero restos y envíos a medias, luego copias repetidas de lo mismo y luego instaladores .pkg. Las aplicaciones solo salen como opción, sin marcar. Nada se borra sin que lo confirmes.

    +
    GB +
    +
    +
    +
    +
    +

    Copia de seguridad

    + +
    +

    Guarda en tu PC lo importante de la consola por si algo falla (un cuelgue, un reinicio o tener que volver a empezar): partidas guardadas, ajustes de ShadowMountPlus y de los payloads, y la base de datos de aplicaciones. Las partidas se guardan tal cual, cifradas: solo se pueden restaurar en esta misma consola y con el mismo usuario. Lo que hay en /data/homebrew no hace falta copiarlo: no se pierde.

    +
    + + + + + +
    + +
    +
    FechaContenidoTamaño
    +
    +
    + +
    +
    +

    Capturas y vídeos

    + + +
    +

    Las capturas de pantalla y los vídeos guardados en la consola. Pulsa una para verla grande; marca las que quieras y «Bajar al PC» las guarda en Descargas\PS5 Directo.

    +
    +
    +
    +
    +

    Actividad

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

    - +
    FechaNombreResultadoTamañoDuraciónDestino
    FechaNombreResultadoTamañoDuraciónDestino
    + + +

    Mover a…

    +
    +
    +
    +
    +
    +
    +

    +
    +
    +
    + +

    Ya existe

    +
    +

    +
      +
      + + + + +
      +

      Reemplazar sobrescribe los archivos con el mismo nombre. Si es una carpeta, se combina: lo que ya tenía y no estás pegando se queda. Conservar ambos le pone (2) al nuevo.

      +
      +
      + +

      Extraer

      +
      +

      La consola lo descomprime en una carpeta al lado del archivo (con BFpilot) y deja el comprimido como está. Nada pasa por el PC.

      + + +

      +
      +
      +
      + + +

      Comprobar arranque

      +
      +

      Revisa la carpeta en la consola y busca registros y capturas de los últimos minutos. Truco: si al abrirla sale un error en la tele, pulsa el botón de captura del mando antes de cerrarlo; la captura aparecerá aquí.

      +
      +
      +
      +
      +
      +
      +
      + +

      Diagnóstico

      +
      +
      +
      +
      +
      Últimas líneas del registro
      +

      El informe es un .zip con el registro, la versión y los ajustes (sin contraseñas). Pásaselo a quien te ayude.

      +
      +
      +
      + +
      +

      Bienvenido a PS5 Directo

      Un gestor de archivos para tu PS5 que descarga, convierte y envía en un solo paso.
      +
      +
      ⚡ Descarga y envía a la vezPegas un enlace y va directo a la consola mientras se descarga, sin llenar el disco del PC.
      +
      🔄 Convierte por el caminoCarpeta, .exfat, LZ4, .ffpfsc… dentro del mismo trabajo, sin usar otro programa.
      +
      📦 Descomprime y colocaRAR, 7z, ZIP y torrents, también por partes y con contraseña. Lo deja listo en /data/homebrew.
      +
      📚 BibliotecaLo que hay en la consola con portadas, versión y firmware, lo instalado de PS4 y PS5, y lo que tienes en el PC.
      +
      💾 Copias de seguridadTus partidas y ajustes de la consola guardados en el PC por si algo falla.
      +
      📱 Móvil y navegadorContrólalo desde el móvil con un QR y manda enlaces desde el navegador sin copiar y pegar.
      +
      +

      No es solo un programa para subir archivos: une descarga, conversión, envío y gestión de la consola en uno. Tampoco instala ni cambia nada del sistema: solo mueve los archivos que tú eliges, y cada uno lo usa bajo su responsabilidad.

      +
      +
      +
      + +

      Restaurar partidas

      +
      +

      Elige qué partidas vuelven a la consola. Antes de restaurar se guarda una copia de las que hay ahora. Cierra en la consola la aplicación de esas partidas antes de seguir.

      + +
      +
      +
      +
      + +

      Hay una versión nueva

      +

      +
      + +
      +

      Novedades

      @@ -321,6 +814,79 @@ tr:hover .rowact{opacity:1}
      + +

      Primeros pasos

      +
      +
      +
      + +

      ¿Qué formato elijo?

      +
      +
      +
      ¿Quieres que funcione sin líos?Sin convertir, Carpeta o Imagen .exfat (lo que usa casi todo el mundo)
      +
      ¿Quieres ahorrar espacio?LZ4, si el título es AMPR (la mayoría). Comprueba que arranca antes de borrar el original.
      +
      ¿No es AMPR o con LZ4 no arranca?Imagen comprimida .ffpfsc: ocupa menos, pero puede ir a tirones.
      +
      ¿Firmware 11.60 o inferior y lo quieres instalado?Paquete .fpkg
      +
      +
      FormatoEspacio en la PS5VelocidadVentajasInconvenientes
      Sin convertir
      Si no quieres complicarte
      Espacio en la PS5: igualVelocidad: ? la del formato que traigaNada que esperar ni que convertir; no usa el disco del PC.Si es carpeta: miles de archivos (envío más lento) y a veces hay que arreglar permisos.
      Carpeta
      Si quieres la máxima velocidad o tocar archivos
      Espacio en la PS5: igualVelocidad: 670-1900 MB/sSin capa de imagen: lo más rápido. Se ven y se cambian archivos sueltos; vale para cualquier cargador.Envío más lento por FTP con muchos archivos; a veces hay que arreglar permisos (CE-107750-0).
      Imagen .exfat
      La más usada: un solo archivo y casi todo la acepta
      Espacio en la PS5: igualVelocidad: 390-550 MB/sEs el formato que más se usa. Un solo archivo: envío más rápido, biblioteca ordenada y sin problemas de permisos. La aceptan casi todas las herramientas, también los conversores a LZ4.ShadowMountPlus recomienda .ffpkg, aunque en la práctica casi todo el mundo usa .exfat.
      LZ4 / LZ4 en imagen
      La favorita para ahorrar espacio (títulos AMPR)
      Espacio en la PS5: menos (depende del contenido)Velocidad: ~540-1500 MB/s (estimado: ~80 % de una carpeta); depende del títuloEmpaqueta los datos en LZ4 y el emulador AMPR los descomprime al vuelo: ocupa menos sin perder tanta velocidad como .ffpfsc.Solo títulos AMPR (la mayoría), y no todos: algunos, como HITMAN 3, leen sus datos sin pasar por el emulador AMPR. La conversión desde el PC todavía falla con algunos títulos: comprueba que arranca antes de borrar el original. Se habla de cargas un 30-40 % más rápidas que con otros comprimidos, pero sin pruebas publicadas.
      Imagen comprimida .ffpfsc
      Ideal si te falta espacio y el contenido no es muy exigente
      Espacio en la PS5: 30-60 % menosVelocidad: 150-250 MB/sAhorra mucho espacio en la consola. Vale para cualquier título.Lee a un tercio de lo que lee un USB y a una décima del disco interno: los títulos que cargan mucho pueden dar tirones. Experimental.
      Imagen .ffpkg
      La que recomienda ShadowMountPlus
      Espacio en la PS5: igualVelocidad: 300-500 MB/sLa que recomienda ShadowMountPlus. Un solo archivo: envío más rápido, biblioteca ordenada y sin problemas de permisos.Poca gente la usa y menos herramientas la aceptan (por ejemplo, los conversores a LZ4 piden .exfat o carpeta). Los archivos de dentro no se tocan sueltos.
      Imagen .ffpfs
      Para probar
      Espacio en la PS5: igualVelocidad: ~150-200 MB/s (estimado)Usa el sistema de archivos de la propia PS5 (PFS).Experimental en ShadowMountPlus; nadie ha publicado cuánto rinde.
      Paquete .fpkg
      Firmware 11.60 o inferior y lo quieres como una app normal
      Espacio en la PS5: se envía comprimido; instalado ocupa lo normalVelocidad: nativa: la del discoSe instala y aparece como una app normal de la consola, sin montar nada.Solo firmware 11.60 o inferior (kstuff, fpkg-enable y ppr-patch). Instalar lleva tiempo y hay que borrar el .pkg después.
      +

      Todos menos «Sin convertir» se preparan en tu PC y ocupan sitio en su disco hasta que se envían. ShadowMountPlus avisa de que montar imágenes puede dar problemas al apagar la consola, sobre todo en firmwares antiguos: ten copia de lo importante.

      +

      Velocidad de lectura aproximada en la consola (de 1 a 8 hilos). Las cifras de .ffpfsc son de la documentación de ShadowMountPlus; el resto son pruebas compartidas por la comunidad, sin verificar. Son orientativas: cada título carga distinto.

      +
      +
      + +

      Antes de convertir

      +
      +

      Con este formato, PS5 Directo prepara el contenido en tu PC antes de enviarlo, con PS5 Dump Forge. Hasta ahora todo pasaba directo a la consola sin ocupar sitio en el PC; con este formato no:

      +
        +
      • Los enlaces se descargan primero al PC.
      • +
      • La conversión necesita sitio libre en el disco del PC (más o menos lo que ocupa el contenido descomprimido).
      • +
      • Al terminar de enviarse, se borra todo del PC.
      • +
      • ShadowMountPlus avisa de que montar imágenes en el disco interno puede dar problemas al apagar la consola, sobre todo en firmwares antiguos: ten copia de lo importante.
      • +
      +

      +

      + +
      +
      +
      + +

      Cargar payloads en la PS5

      +
      +

      Manda el payload al cargador de la consola (etaHEN o elfldr, puerto 9021). Se descarga la última versión de GitHub y se guarda para la próxima vez. El cargador de Y2JB Autoloader solo acepta la propia consola: con él usa Payload Manager.

      +
      +
      +
      +
      + +

      ¿Por qué va lento?

      +
      +

      Revisa cómo llega el PC a la PS5: Wi-Fi o cable, banda y señal, cables a 100 Mb/s, VPN, redes distintas y el ping a la consola. Tarda unos 15 segundos. Para medir velocidades usa también el test de velocidad.

      +
      +
      +
      +
      + +

      Programar la cola

      +
      +
      +
      +
      +
      +

      Lo que ya se esté enviando sigue; lo nuevo espera a esa hora. Antes de apagar o suspender te avisa y espera 1 minuto, por si quieres cancelarlo. Mientras hay envíos, el PC no se suspende solo (se puede cambiar en Ajustes).

      +
      +
      +
      + +

      Abrir en el móvil

      +
      + +
      +
      +
      +

      Escanea el código con la cámara del móvil (conectado a la misma red). Desde el móvil puedes pegar enlaces, ver la cola, pausar, ver tu biblioteca y navegar por la consola. Por seguridad no se pueden instalar actualizaciones ni cambiar las rutas de programas. Windows puede preguntar si permites el acceso: elige «Redes privadas». Con esto activado, PS5 Directo sigue abierto aunque cierres la ventana (ciérralo con Salir).

      +
      +
      +

      Ajustes

      @@ -328,6 +894,10 @@ tr:hover .rowact{opacity:1}
      +
      +
      Se guarda solo al escribirlo.
      +
      +
      @@ -354,6 +924,9 @@ tr:hover .rowact{opacity:1}
      + + +
      Descomprimir en el PC

      Alternativa a BFpilot: 7-Zip descomprime en el PC sobre la marcha y la app manda los archivos ya sueltos, comprobando cada uno con su CRC. No escribe nada descomprimido en el PC y en la PS5 solo ocupa el contenido final (sin el RAR al lado). A cambio, por el Wi-Fi viaja todo sin comprimir (puede ser 3-4 veces más) y los enlaces se bajan antes al PC (hace falta sitio para el RAR).

      @@ -363,10 +936,25 @@ tr:hover .rowact{opacity:1}
      +
      + Imágenes y LZ4 (PS5 Dump Forge) +

      Con PS5 Dump Forge el contenido se prepara en el PC en el formato que elijas: imagen .ffpkg (recomendada por ShadowMountPlus), .exfat, .ffpfs o .ffpfsc (más pequeña pero más lenta de leer), paquete .fpkg (solo firmware 11.60 o inferior), carpeta normal o empaquetado en LZ4 (solo títulos AMPR, experimental: algunos se cuelgan al guardar). Se envía un único archivo, que va más rápido por FTP que miles pequeños y sin el problema de permisos. Hace falta sitio en el PC para la imagen. Se descarga solo de GitHub la primera vez.

      +
      +
      +
      +
      +

      +
      +
      + Herramientas +
      +
      + +
      @@ -403,12 +991,57 @@ tr:hover .rowact{opacity:1}
      +