From fdc42abe0abdc326dd3ddad50c5f54d926b47bc1 Mon Sep 17 00:00:00 2001 From: JesterDev <105895386+jstrdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:30:42 +0200 Subject: [PATCH] =?UTF-8?q?Versi=C3=B3n=201.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Crear accesos directos.bat | 19 + Iniciar PS5 Directo.bat | 14 + LEEME.txt | 263 +++++ PS5 Directo.vbs | 21 + README.md | 31 +- app.py | 2204 ++++++++++++++++++++++++++++++++++++ engine.py | 1233 ++++++++++++++++++++ extension/bg.js | 70 ++ extension/icon128.png | Bin 0 -> 11923 bytes extension/icon16.png | Bin 0 -> 778 bytes extension/icon32.png | Bin 0 -> 1816 bytes extension/icon48.png | Bin 0 -> 3067 bytes extension/manifest.json | 11 + extension/popup.html | 20 + extension/popup.js | 26 + web/index.html | 626 ++++++++++ web/logo.svg | 30 + web/ps5directo.ico | Bin 0 -> 63686 bytes 18 files changed, 4566 insertions(+), 2 deletions(-) create mode 100644 Crear accesos directos.bat create mode 100644 Iniciar PS5 Directo.bat create mode 100644 LEEME.txt create mode 100644 PS5 Directo.vbs create mode 100644 app.py create mode 100644 engine.py create mode 100644 extension/bg.js create mode 100644 extension/icon128.png create mode 100644 extension/icon16.png create mode 100644 extension/icon32.png create mode 100644 extension/icon48.png create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 web/index.html create mode 100644 web/logo.svg create mode 100644 web/ps5directo.ico diff --git a/Crear accesos directos.bat b/Crear accesos directos.bat new file mode 100644 index 0000000..7af9342 --- /dev/null +++ b/Crear accesos directos.bat @@ -0,0 +1,19 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$w=New-Object -ComObject WScript.Shell; $d=(Get-Location).Path;" ^ + "foreach($f in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('SendTo'))){" ^ + " $l=$w.CreateShortcut((Join-Path $f 'PS5 Directo.lnk'));" ^ + " $l.TargetPath=(Join-Path $env:WINDIR 'System32\wscript.exe');" ^ + " $l.Arguments='\"'+(Join-Path $d 'PS5 Directo.vbs')+'\"';" ^ + " $l.WorkingDirectory=$d; $l.IconLocation=(Join-Path $d 'web\ps5directo.ico');" ^ + " $l.Description='PS5 Directo'; $l.Save() }" +if %errorlevel%==0 ( + echo Listo: + echo - Icono "PS5 Directo" en el escritorio. Tambien puedes soltar archivos encima. + echo - Clic derecho en archivos ^> Enviar a ^> PS5 Directo. +) else ( + echo No se pudieron crear los accesos directos. +) +pause diff --git a/Iniciar PS5 Directo.bat b/Iniciar PS5 Directo.bat new file mode 100644 index 0000000..9e78250 --- /dev/null +++ b/Iniciar PS5 Directo.bat @@ -0,0 +1,14 @@ +@echo off +chcp 65001 >nul +title PS5 Directo +cd /d "%~dp0" +where py >nul 2>nul +if %errorlevel%==0 ( + py -3 app.py %* +) else ( + python app.py %* +) +if %errorlevel%==3 exit /b 0 +echo. +echo PS5 Directo se ha cerrado. +pause diff --git a/LEEME.txt b/LEEME.txt new file mode 100644 index 0000000..32ddd7e --- /dev/null +++ b/LEEME.txt @@ -0,0 +1,263 @@ +PS5 DIRECTO 1.14 +================ + +Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola: + + 1. Descarga los archivos y los manda a la PS5 por FTP mientras bajan + (nada se guarda en el disco del PC). + 2. Une las partes troceadas (.001, .002...) en un solo archivo. + 3. Pide a BFpilot que descomprima el RAR/7z/ZIP DENTRO de la consola, + probando las contraseñas que conozca. + 4. Busca la carpeta de la aplicación (la que tiene sce_sys) y la deja en + /data/homebrew, donde ShadowMountPlus la detecta sola. + 5. Borra los comprimidos de la PS5. + +Mientras una transferencia se descomprime en la PS5, la siguiente de la cola ya se +va enviando. + +ALTERNATIVA: DESCOMPRIMIR EN EL PC (7-Zip) + En "Dónde descomprimir" elige "En el PC (7-Zip)" (o ponlo por defecto en + Ajustes). Entonces: + - Los enlaces se bajan antes al PC (carpeta temporal de Ajustes; hace + falta sitio para el RAR, no para lo descomprimido). Tus archivos locales se + usan tal cual y nunca se borran. + - 7-Zip descomprime sobre la marcha y la app manda cada archivo ya + suelto a la PS5. Lo descomprimido NO se escribe en el PC. + - Cada archivo se comprueba con su CRC antes de darlo por bueno. + - En la PS5 solo ocupa el contenido final (sin el RAR al lado) y se + sabe exacto antes de empezar. + - Pega: por el Wi-Fi viaja todo sin comprimir (puede ser 3-4 veces + más que el RAR), así que tarda más en enviarse. + - Pausar/Reintentar sigue por el archivo donde iba. + Necesita 7-Zip instalado (gratis, 7-zip.org). No necesita BFpilot. + + +LO QUE NECESITAS +---------------- +Esta guía no explica cómo hacer el jailbreak: das por hecho que tu PS5 ya +lo tiene y que sabes cargar payloads (.elf). + +EN EL PC +- Windows 10 u 11. +- Python 3.9 o superior: https://www.python.org/downloads/ + Al instalarlo marca "Add python.exe to PATH". +- 7-Zip (solo si usas "Descomprimir en el PC"): https://www.7-zip.org/ + +EN LA PS5 (se cargan como payloads, por ejemplo con Payload Manager: +https://github.com/itsPLK/ps5-payload-manager ) +- Un servidor FTP. El de referencia es ftpsrv (puerto 2121): + https://github.com/ps5-payload-dev/ftpsrv + Muchos menús de payloads ya lo traen. Si el tuyo usa otro puerto, + cámbialo en Ajustes. +- BFpilot (puerto 5905): descomprime dentro de la consola, mueve y borra. + Descarga bfpilot.elf de: https://github.com/ItsBlurf/BFpilot/releases + Si no está cargado, el programa espera a que lo cargues. En el modo + "Descomprimir en el PC" no hace falta. +- ShadowMountPlus: es el que hace que las aplicaciones de /data/homebrew + aparezcan en el menú de la PS5: + https://github.com/drakmor/ShadowMountPlus + +Para comprobar que todo está bien: Ajustes > "Revisión completa". + + +SOBRE BFPILOT Y LOS PERMISOS (importante si lo usas por tu cuenta) +----------------------------------------------------------------- +Hasta su versión 0.4.4 al menos, cuando BFpilot descomprime un RAR, 7z o +ZIP deja los archivos sin permiso de ejecución, y la PS5 se niega a abrir +la aplicación (error CE-107750-0). Pasa también si descomprimes desde la web de +BFpilot, sin PS5 Directo. +- PS5 Directo lo arregla solo después de cada descompresión. +- Para lo que ya tenías en la consola: Ajustes > "Reparar permisos". +- A mano: en FileZilla, clic derecho en la carpeta de la aplicación > + Permisos de archivo > 777 > "Recurrir a subdirectorios". + + +PRIMERA VEZ +----------- +Doble clic en "Crear accesos directos.bat". Crea: + - Un icono "PS5 Directo" en el escritorio. + - "Enviar a > PS5 Directo" en el clic derecho de los archivos. +Desde entonces abre PS5 Directo con el icono del escritorio: sale en su +propia ventana, sin pestañas y sin ventana negra. + +(Si algo falla al abrir, usa "Iniciar PS5 Directo.bat": es lo mismo pero +con la ventana negra, donde se ven los errores. Registro sin ventana: +%USERPROFILE%\.ps5directo\ps5directo.log) + +Si cierras la ventana con trabajos en marcha, siguen en segundo plano. +Vuelve a abrir PS5 Directo para verlos. El botón "Salir" lo cierra del +todo (lo que esté a medias sigue luego con "Reintentar"). + + +CÓMO SE USA +----------- +1. Descomprime esta carpeta donde quieras. +2. Abre PS5 Directo con el icono del escritorio. +3. La primera vez se abre Ajustes: pon la IP de tu PS5 (la ves en la + PS5 en Ajustes > Red > Ver estado de la conexión), pulsa "Guardar" y + "Probar conexión". Los dos puntos de arriba deben ponerse en verde. +4. Pega los enlaces directos (todas las partes juntas, en el + orden que sea) y pulsa "Añadir a la cola". + - Si sabes la contraseña, escríbela. Si no, prueba las de Ajustes y + lo que vaya entre corchetes en el nombre del archivo, como [WEB.COM]. + - También vale la ruta de un archivo que ya tengas en el PC. + +5. ARCHIVOS DEL PC SIN COPIAR RUTAS: + - Arrastra uno o varios archivos sobre el icono del escritorio. + - O clic derecho en los archivos > Enviar a > PS5 Directo. + Si PS5 Directo ya está abierto, se añaden a su cola. + - O suéltalos en la página del navegador: PS5 Directo los busca en + tu PC por nombre y tamaño y pone sus rutas en el cuadro de enlaces. + + +PAUSA, LÍMITE Y ESPACIO +----------------------- +- "Pausar" detiene el envío; "Reanudar" sigue donde iba. +- Ajustes > Límite de velocidad (MB/s): para usar internet mientras envía. + Se aplica al momento. 0 = sin límite. +- Ajustes > Dejar siempre libre (GB, 25 por defecto): antes de empezar + comprueba que cabe lo que falta por enviar, y mientras envía o + descomprime vigila el espacio. Si baja de ese margen, lo para todo + antes de que la PS5 se llene. Libera espacio y pulsa Reintentar. +- Algunos alojamientos de archivos limitan las conexiones: con ellos usa + solo 2 por archivo, y menos si el servidor empieza a dar errores. + + +ACTUALIZAR Y EXTENSIÓN +---------------------- +- Botón "Actualizar" (arriba): si el programa tiene un repositorio de + GitHub configurado, busca la versión nueva y la instala. Si no, te + deja elegir el .zip de la versión nueva. Guarda una copia de la versión + anterior en la carpeta .backup. +- Botón "Extensión" (arriba): abre la carpeta de la extensión para + Chrome/Edge y las instrucciones. Con ella, al pulsar Descargar en una + web, el enlace va directo a PS5 Directo sin copiar y pegar. + + +TEST DE VELOCIDAD +----------------- +En una transferencia pausada o terminada, "Test de velocidad" mide (unos 30 s): +descarga sola, envio PC -> PS5 solo y las dos cosas a la vez, y te dice +de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en +Ajustes. + + +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. + +NOVEDADES 1.13.1 +- El logo de la cabecera va dentro de la página (al actualizar desde una + versión anterior no se copiaba y salía roto). + +NOVEDADES 1.13 +- Icono nuevo y fondo con símbolos. Para que el icono nuevo salga en el + escritorio, vuelve a ejecutar "Crear accesos directos.bat". + +NOVEDADES 1.12.1 +- Textos más neutros: "transferencias" y "aplicaciones", como una + herramienta de transferencia de archivos. + +NOVEDADES 1.12 +- Botones "Actualizar" y "Extensión" en la pantalla principal. Si hay + versión nueva en GitHub, el botón lo avisa. +- La primera vez pide la IP de la PS5 (ya no trae una puesta). +- Guía de lo que hace falta y de dónde sacarlo (arriba). + +NOVEDADES 1.11 +- ARREGLO IMPORTANTE: lo descomprimido con BFpilot no arrancaba + (CE-107750-0). Su unrar deja los archivos con los permisos que trae el + RAR (666/444, sin permiso de ejecución). Ahora PS5 Directo les pone 777 + justo al colocarlos, y lo apunta en el registro. +- Ajustes > "Reparar permisos": arregla las aplicaciones que ya tienes en la + consola (los que pasaste antes con BFpilot) sin volver a bajarlos. +- Modo PC: si BFpilot no está cargado, lo coloca por FTP (ya no + hace falta BFpilot para nada en ese modo). + +NOVEDADES 1.10.1 +- En el modo PC los pasos son "Bajar al PC → Descomprimir y enviar → Colocar + → Listo", y dice por qué archivo va (37 de 129...). + +NOVEDADES 1.10 +- Nueva opción: descomprimir en el PC con 7-Zip y mandar el contenido ya + suelto, comprobado archivo a archivo (ver arriba). +- La "Revisión completa" dice si encuentra 7-Zip. + +NOVEDADES 1.9 +------------- +- EXTENSION PARA CAPTURAR ENLACES (Chrome o Edge), en la carpeta "extension": + 1. Abre chrome://extensions (o edge://extensions). + 2. Activa "Modo de desarrollador" (arriba a la derecha). + 3. "Cargar descomprimida" y elige la carpeta "extension" de PS5 Directo. + Desde entonces, pasa el captcha y pulsa Descargar como siempre: si es un + .rar/.7z/.zip/.pkg y PS5 Directo esta abierto, el enlace bueno (despues de + redirecciones) aparece en el cuadro de enlaces y el navegador NO descarga + nada. Cuando esten todas las partes, pulsa "Anadir a la cola". + Si PS5 Directo esta cerrado, la descarga sigue normal. Se apaga y enciende + desde su icono. +- ACTUALIZAR: Ajustes > "Instalar actualizacion (.zip)": eliges el zip nuevo y + se instala solo, guarda copia de la version anterior en .backup y se + reinicia. Si algun dia lo subes a GitHub, pon "usuario/repositorio" y el + boton "Buscar actualizacion" lo descarga de las releases. +- La version sale arriba, junto al titulo, y en el nombre de la ventana. + + +NOVEDADES 1.8 +------------- +- Mientras descomprime ves "leido del RAR X de Y" (lo que BFpilot ha + procesado del comprimido) y "descomprimido" (lo que ocupa de verdad en + la PS5). Una aplicación en formato carpeta puede ocupar varias veces el RAR: + Lost Records, 35 GB de RAR -> 145 GB de carpeta. +- Las previsiones de espacio cuentan con hasta ~5 veces el tamano del RAR. +- Si el RAR trae la aplicación dentro de una carpeta, se coloca igual como + TITLEID-app. +- La version aparece arriba, junto al titulo. + + +NOVEDADES 1.7 +------------- +- Ajustes > Revision completa: comprueba FTP, escritura, BFpilot, formatos + y espacio, y dice que hacer si algo falla. +- Ajustes > Limpieza de la PS5: busca restos en /data/ps5directo, ensena + cuanto ocupan y los borra (nunca toca nada fuera de esa carpeta). +- Aviso si el Title ID ya existe en /data/homebrew o en un USB/externo. +- Destino: sugiere /mnt/usbX/homebrew si hay unidades conectadas. +- Ajustes > Hilos al descomprimir (0 = automatico). Si una descompresion + se cuelga, prueba con 1. +- Si la descompresion no da senales de vida en 5 min, lo avisa. +- Aviso de Windows al terminar o fallar (acepta el permiso la 1a vez). + + +SI ALGO FALLA +------------- +- Se corta la red o el FTP: reintenta solo y continúa donde se quedó. +- Cierras el programa a medias: al abrirlo, pulsa "Reintentar". + Continúa desde lo que ya había en la consola. +- Contraseña incorrecta: pulsa "Reintentar" y escríbela. + No se vuelve a descargar nada. +- "Ya existe": no pisa nada que ya tengas. Bórralo o renómbralo y + pulsa "Reintentar". +- Los archivos de trabajo están en /data/ps5directo (fuera de las + carpetas que escanea ShadowMountPlus). + + +ENLACES +------- +Funcionan los enlaces de descarga directa. Pixeldrain, Dropbox y algunos +de Mediafire y Google Drive se convierten solos. Las páginas que piden +captcha o iniciar sesión no funcionan: copia el enlace final de descarga. + +Los .pkg se dejan en /data/pkg para instalarlos con ps5upload. +Las imágenes (.exfat, .ffpkg, .ffpfsc) van a /data/homebrew. + +Ajustes y cola se guardan en %USERPROFILE%\.ps5directo + + +AVISO +----- +PS5 Directo es un programa independiente, sin relación con Sony ni con +los autores de BFpilot, ftpsrv o ShadowMountPlus. Es una herramienta de +transferencia de archivos: no incluye contenido de terceros, claves ni +archivos de Sony, ni enlaces a sitios de descargas. Úsalo solo con +contenido que tengas derecho a usar (tus copias de seguridad y homebrew). +Cada uno es responsable de lo que transfiere. diff --git a/PS5 Directo.vbs b/PS5 Directo.vbs new file mode 100644 index 0000000..8511091 --- /dev/null +++ b/PS5 Directo.vbs @@ -0,0 +1,21 @@ +' Abre PS5 Directo sin ventana negra (en su propia ventana de aplicacion). +' Puedes arrastrar archivos encima de este archivo o de su acceso directo. +Set sh = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") +dir = fso.GetParentFolderName(WScript.ScriptFullName) +sh.CurrentDirectory = dir +args = "" +For Each a In WScript.Arguments + args = args & " """ & a & """" +Next +py = "" +If sh.Run("cmd /c where pyw >nul 2>nul", 0, True) = 0 Then + py = "pyw -3" +ElseIf sh.Run("cmd /c where pythonw >nul 2>nul", 0, True) = 0 Then + py = "pythonw" +End If +If py = "" Then + MsgBox "No encuentro Python. Instala Python 3 (marca 'Add to PATH') o usa 'Iniciar PS5 Directo.bat'.", 48, "PS5 Directo" +Else + sh.Run py & " """ & dir & "\app.py""" & args, 0, False +End If diff --git a/README.md b/README.md index 6921df1..b2a635b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ -# ps5directo -Herramienta de transferencia de archivos a PS5 por red local +# PS5 Directo + +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ú. + +- Descarga en paralelo, con pausa, reanudación y límite de velocidad. +- Vigila el espacio libre de la PS5 para no llenarla. +- Corrige los permisos de lo descomprimido con BFpilot. Sin esa corrección no arranca (CE-107750-0). +- Extensión opcional para Chrome/Edge que manda los enlaces directamente al programa. +- Se actualiza desde las releases de este repositorio. + +## Requisitos + +- **PC:** Windows 10/11, [Python 3.9+](https://www.python.org/downloads/) y, para descomprimir en el PC, [7-Zip](https://www.7-zip.org/). +- **PS5 con jailbreak:** + - Servidor FTP, por ejemplo [ftpsrv](https://github.com/ps5-payload-dev/ftpsrv) en el puerto 2121. + - [BFpilot](https://github.com/ItsBlurf/BFpilot) en el puerto 5905. + - [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus). + +## Uso + +1. Descarga el `.zip` de la última [release](../../releases) y descomprímelo. +2. Ejecuta `Crear accesos directos.bat` y abre **PS5 Directo** desde el escritorio. +3. Pon la IP de tu PS5 en Ajustes y pega los enlaces o arrastra los archivos. + +La guía completa está en [LEEME.txt](LEEME.txt). + +## Aviso + +Proyecto independiente, sin relación con Sony ni con los autores de las herramientas citadas. No incluye contenido de terceros, claves ni archivos de Sony, ni enlaces a sitios de descargas. Úsalo solo con contenido que tengas derecho a usar (copias de seguridad propias y homebrew). diff --git a/app.py b/app.py new file mode 100644 index 0000000..525fa07 --- /dev/null +++ b/app.py @@ -0,0 +1,2204 @@ +"""PS5 Directo: transfiere archivos a la consola, los descomprime y los deja en su sitio. + +Arranque: python app.py (o doble clic en "Iniciar PS5 Directo.bat") +""" + +import json +import re +import os +import posixpath +import secrets +import socket +import sys +import threading +import time +import traceback +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import engine as E # noqa: E402 + +VERSION = "1.14.0" +# Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas, +# todas las copias buscan actualizaciones ahi sin tener que configurar nada. +DEFAULT_REPO = "" +DATA_DIR = os.path.join(os.path.expanduser("~"), ".ps5directo") +SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") +JOBS_FILE = os.path.join(DATA_DIR, "jobs.json") +WORK_ROOT = "/data/ps5directo" # fuera de las rutas que escanea ShadowMountPlus + +DEFAULTS = { + "ps5_ip": "", + "ftp_port": 2121, + "ftp_user": "anonymous", + "ftp_pass": "", + "bf_port": 5905, + "dest": "/data/homebrew", + "streams": 8, + "parallel_files": 2, + "limit_mbps": 0, # MB/s maximos hacia la PS5 (0 = sin limite) + "space_margin_gb": 25, # GB que siempre se dejan libres en la PS5 + "update_repo": "", # "usuario/repositorio" de GitHub para buscar actualizaciones + "extract_threads": 0, # hilos de BFpilot al descomprimir (0 = automatico, max 8) + "extract_where": "ps5", # "ps5" = BFpilot en la consola; "pc" = 7-Zip en el PC sobre la marcha + "pc_temp_dir": "", # carpeta del PC donde bajar los RAR en modo "pc" (vacio = Descargas) + "sevenzip_path": "", # ruta de 7z.exe (vacio = buscarlo) + "chunk_mb": 4, + "buffer_mb": 256, + "retries": 8, + "passwords": [], + "guess_passwords": True, + "delete_archives": True, + "tour_done": False, # la guia de bienvenida ya se vio +} + +LOCK = threading.RLock() +SETTINGS = dict(DEFAULTS) +JOBS = [] # lista de dicts (orden de cola) +CANCELS = {} # id -> threading.Event +WAKE = threading.Condition(LOCK) +EXTRACT_Q = [] # ids pendientes de extraer + + +# --------------------------------------------------------------------------- +# Persistencia +# --------------------------------------------------------------------------- + +def _save_json(path, obj): + os.makedirs(DATA_DIR, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(obj, f, ensure_ascii=False, indent=1) + os.replace(tmp, path) + + +def load_state(): + global JOBS + try: + with open(SETTINGS_FILE, encoding="utf-8") as f: + SETTINGS.update(json.load(f)) + except Exception: + pass + # 1.8.1: la 1.8.0 puso 1 hilo por error; volver a automatico una sola vez + if SETTINGS.get("_threads_v18") and not SETTINGS.get("_threads_v181"): + if int(SETTINGS.get("extract_threads") or 0) == 1: + SETTINGS["extract_threads"] = 0 + SETTINGS["_threads_v181"] = True + try: + with open(JOBS_FILE, encoding="utf-8") as f: + JOBS = json.load(f) + except Exception: + JOBS = [] + for j in JOBS: + if j["status"] not in ("hecho", "error", "cancelado"): + j["status"] = "error" + j["error"] = "Se cerro el programa a medias. Pulsa Reintentar: continua donde se quedo." + + +def save_jobs(): + with LOCK: + _save_json(JOBS_FILE, JOBS) + + +def save_settings(): + with LOCK: + _save_json(SETTINGS_FILE, SETTINGS) + + +# --------------------------------------------------------------------------- +# Utilidades de trabajos +# --------------------------------------------------------------------------- + +def new_job(lines, password, dest, title=None, where=None): + return { + "where": where if where in ("ps5", "pc") else "", + "id": secrets.token_hex(4), + "created": time.time(), + "title": title or "", + "lines": lines, + "sources": [], + "password": password or "", + "dest": dest, + "status": "pendiente", + "phase": "En cola", + "sent": 0, "total": -1, "speed": 0.0, + "extract_pct": 0, "extract_text": "", + "error": "", "result": [], "log": [], + } + + +def find(job_id): + for j in JOBS: + if j["id"] == job_id: + return j + return None + + +def jlog(job, msg): + line = time.strftime("%H:%M:%S ") + msg + with LOCK: + job["log"].append(line) + del job["log"][:-300] + try: + print("[%s] %s" % (job["id"], msg)) + except Exception: # consola de Windows sin UTF-8 + print(("[%s] %s" % (job["id"], msg)).encode("ascii", "replace").decode()) + + +def setj(job, **kw): + with LOCK: + job.update(kw) + + +def cfg_snapshot(): + with LOCK: + return dict(SETTINGS) + + +def human(n): + n = float(n) + for u in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or u == "TB": + return "%.1f %s" % (n, u) if u != "B" else "%d B" % n + n /= 1024 + + +class Meter: + """Velocidad media de los ultimos segundos.""" + + def __init__(self): + self.lock = threading.Lock() + self.samples = [] + self.total = 0 + + def __call__(self, n): + with self.lock: + self.total += n + now = time.time() + self.samples.append((now, self.total)) + while self.samples and now - self.samples[0][0] > 6: + self.samples.pop(0) + + def rate(self): + """Media de los ultimos segundos. Si hay pocas muestras muy juntas (arranque, + reanudar), se divide al menos entre 2 s para no inventar velocidades absurdas.""" + with self.lock: + now = time.time() + while self.samples and now - self.samples[0][0] > 6: + self.samples.pop(0) + if len(self.samples) < 2: + return 0.0 + (t0, b0), (t1, b1) = self.samples[0], self.samples[-1] + if now - t1 > 3: # lleva rato sin llegar nada + return 0.0 + return (b1 - b0) / max(2.0, t1 - t0) + + +# --------------------------------------------------------------------------- +# Fase 1: comprobar enlaces y agrupar por conjunto de archivos +# --------------------------------------------------------------------------- + +def probe_job(job, cancel): + setj(job, status="comprobando", phase="Comprobando enlaces") + srcs = [] + for line in job["lines"]: + if cancel.is_set(): + raise E.Cancelled() + s = E.make_source(line) + jlog(job, "Comprobando: %s" % line[:120]) + s.probe() + jlog(job, " %s (%s)%s" % (s.name, human(s.size) if s.size >= 0 else "tamano desconocido", + "" if s.ranges else " sin descarga en paralelo")) + srcs.append(s) + return srcs + + +def split_into_sets(srcs): + groups = {} + order = [] + for s in srcs: + k = E.set_key(s.name) + if k not in groups: + groups[k] = [] + order.append(k) + groups[k].append(s) + return [sorted(groups[k], key=lambda s: E.volume_order(s.name)) for k in order] + + +def describe(srcs): + return [{"input": s.original, "name": s.name, "size": s.size, "kind": s.kind} for s in srcs] + + +# --------------------------------------------------------------------------- +# Fase 2: enviar a la PS5 +# --------------------------------------------------------------------------- + +GiB = 1024 ** 3 +RUNAWAY_SLACK = int(os.environ.get("PS5D_RUNAWAY_SLACK", 2 * 1024 ** 3)) + + +def free_space(bf): + """Bytes libres en /data segun BFpilot, o None si no responde.""" + try: + st = bf._req("GET", "/api/fs/stat", {"path": "/data"}) + v = st.get("availableBytes") + return int(v) if v is not None else None + except Exception: + return None + + +def margin_bytes(): + with LOCK: + return max(0, int(SETTINGS.get("space_margin_gb", 25) or 0)) * GiB + + +def start_space_watch(job, bf, cancel, what): + """Vigila el espacio libre mientras se envia o descomprime. Si baja del margen, + lo para todo antes de que la PS5 se quede sin sitio. Devuelve un Event para pararlo.""" + done = threading.Event() + margin = margin_bytes() + if margin <= 0: + return done + + def loop(): + low_logged = False + while not done.wait(15): + free = free_space(bf) + if free is None: + continue + if free < margin: + with LOCK: + job["_stop"] = ("Parado para no llenar la PS5 (%s): quedan %s libres y el margen " + "es %s. Libera espacio y pulsa Reintentar; sigue donde iba." + % (what, human(free), human(margin))) + jlog(job, "ESPACIO: quedan %s libres (< %s). Paro %s." % (human(free), human(margin), what)) + cancel.set() + return + if free < margin * 2 and not low_logged: + jlog(job, "Aviso: queda poco espacio en la PS5 (%s libres)" % human(free)) + low_logged = True + + threading.Thread(target=loop, daemon=True).start() + return done + + +def adopt_previous(job, bf, inc, names): + """Mueve a 'inc' las partes que otros trabajos ya mandaron (completas o a medias).""" + root = WORK_ROOT + "/incoming" + try: + dirs = [e["name"] for e in bf.listdir(root) if e.get("dir")] + except Exception: + return + try: + bf.mkdir(inc) + except Exception: + pass + want = set(names) | {n + ".ps5part" for n in names} + for d in dirs: + path = "%s/%s" % (root, d) + if path == inc: + continue + try: + entries = bf.listdir(path) + except Exception: + continue + for e in entries: + if e["name"] in want and not bf.exists("%s/%s" % (inc, e["name"])): + try: + bf.rename("%s/%s" % (path, e["name"]), "%s/%s" % (inc, e["name"])) + jlog(job, "Aprovecho lo ya enviado: %s" % e["name"]) + except Exception: + pass + + +def send_parts_parallel(job, cfg, srcs, inc, cancel, meter, set_sent): + """Envia las partes de un comprimido, varias a la vez (cada una por su propia + conexion FTP). En Wi-Fi, dos conexiones suman mas que una sola.""" + n = max(1, min(int(cfg.get("parallel_files", 2)), 4, len(srcs))) + if n > 1: + cfg = dict(cfg) + cfg["buffer_mb"] = max(64, int(cfg.get("buffer_mb", 256)) // n) + stop = threading.Event() # se activa si cancelas o si una parte falla + done = {} # parte -> bytes enviados + errors = [] + lock = threading.Lock() + pending = list(srcs) + + def relay_cancel(): + while not stop.is_set(): + if cancel.wait(0.5): + stop.set() + + def progress(name, v): + with lock: + done[name] = v + total = sum(done.values()) + set_sent(total) + + def phase(): + with lock: + active = [s.name for s in srcs if 0 < done.get(s.name, 0) < max(1, s.size)] + if active: + setj(job, phase="Enviando " + " + ".join(active)) + + def runner(): + while not stop.is_set(): + with lock: + if not pending: + return + s = pending.pop(0) + done.setdefault(s.name, 0) + try: + E.upload(cfg, "%s/%s" % (inc, s.name), s.size, + lambda off, s=s: s.stream(off, cfg, stop, meter), + lambda v, s=s: (progress(s.name, v), phase()), + stop, lambda msg: jlog(job, msg)) + progress(s.name, max(0, s.size)) + except Exception as e: + with lock: + errors.append(e) + stop.set() + return + + threading.Thread(target=relay_cancel, daemon=True).start() + if n > 1: + jlog(job, "Enviando %d partes a la vez" % n) + threads = [threading.Thread(target=runner, daemon=True) for _ in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + stop.set() + if cancel.is_set(): + raise E.Cancelled() + real = [e for e in errors if not isinstance(e, E.Cancelled)] + if real: + raise real[0] + if errors: + raise errors[0] + + +def live_limit_bps(): + with LOCK: + return float(SETTINGS.get("limit_mbps", 0) or 0) * 1024 * 1024 + + +def transfer_job(job, srcs, cancel): + cfg = cfg_snapshot() + cfg["_log"] = lambda m: jlog(job, m) + cfg["_limit_bps"] = live_limit_bps + names = [s.name for s in srcs] + archives = [s for s in srcs if E.is_archive(s.name)] + if archives and job_where(job) == "pc": + return pc_extract_job(job, srcs, cancel) + m = E.SPLIT_RAW_RE.match(srcs[0].name) + raw_split = (not archives and len(srcs) >= 1 and m + and all(E.SPLIT_RAW_RE.match(s.name) for s in srcs)) + known = all(s.size >= 0 for s in srcs) + total = sum(s.size for s in srcs) if known else -1 + # Carpeta por conjunto de archivos (no por trabajo): si mandas luego las partes que faltaban + # en otro trabajo, aprovecha lo que ya estaba en la consola. + inc = "%s/incoming/%s" % (WORK_ROOT, E.safe_name(E.set_key(srcs[0].name)) or job["id"]) + meter = Meter() # descarga (lo que entra al PC) + up = Meter() # envio a la PS5 (lo que importa) + stop_speed = threading.Event() + last_sent = {"v": None} + sent_lock = threading.Lock() + + def set_sent(v): + # Varias partes avisan a la vez y los avisos pueden llegar desordenados: + # solo cuenta lo que supera el maximo ya visto (nunca dos veces lo mismo). + with sent_lock: + prev = last_sent["v"] + if prev is not None and v <= prev: + return + if prev is not None and v - prev < 64 * 1024 * 1024: + up(v - prev) + last_sent["v"] = v + setj(job, sent=v) + + def speed_loop(): + while not stop_speed.wait(1): + setj(job, speed=up.rate(), dl_speed=meter.rate()) + + threading.Thread(target=speed_loop, daemon=True).start() + setj(job, status="enviando", total=total, sent=0) + bf = E.BFpilot(cfg) + + adopt_previous(job, bf, inc, [s.name for s in srcs]) + warn_duplicates(job, bf, E.title_id(srcs[0].name), + when=" (aviso antes de enviar: puedes pausarlo si no lo quieres)") + + # Comprobacion de espacio: lo que falta por enviar + margen. La descompresion la + # vuelve a comprobar BFpilot antes de empezar (sabe lo que ocupa de verdad). + free = free_space(bf) + if free is not None and total > 0: + present = 0 + try: + want = {s.name: s.size for s in srcs} + for e in bf.listdir(inc): + n = e.get("name", "") + base = n[:-8] if n.endswith(".ps5part") else n + if base in want: + present += min(int(e.get("size") or 0), max(0, want[base])) + except Exception: + pass + remaining = max(0, total - present) + margin = margin_bytes() + if remaining + margin > free: + stop_speed.set() + raise E.PipeError("No hay sitio en la PS5: faltan por enviar %s, hay %s libres y dejo %s " + "de margen (Ajustes). Libera espacio y pulsa Reintentar." + % (human(remaining), human(free), human(margin))) + if archives: + est = int(total * 5) + if free - remaining - est < margin: + jlog(job, "Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR (~%s) y no se " + "si cabra. Si al descomprimir baja del margen, lo paro y los RAR se quedan " + "en la PS5." % human(est)) + jlog(job, "Espacio libre en la PS5: %s" % human(free)) + watch = start_space_watch(job, bf, cancel, "el envio") + done_before = 0 + try: + if archives or len(srcs) > 1 and not raw_split: + # Volumenes de un archivo comprimido: cada uno a su sitio, separados + send_parts_parallel(job, cfg, srcs, inc, cancel, meter, set_sent) + return {"mode": "archive", "dir": inc, "first": E.first_volume(names)} + if raw_split: + # .001 .002 ... -> se unen en un solo archivo mientras se envian + base_name = E.SPLIT_RAW_RE.match(srcs[0].name).group("base") + if not known: + raise E.PipeError("Para unir partes hace falta saber el tamano de cada una") + remote = "%s/%s" % (inc, base_name) + setj(job, phase="Enviando y uniendo %d partes en %s" % (len(srcs), base_name)) + jlog(job, "Uniendo %d partes en %s" % (len(srcs), base_name)) + E.upload(cfg, remote, total, + lambda off: E.concat_stream(srcs, off, cfg, cancel, meter), + lambda n: set_sent(n), cancel, lambda msg: jlog(job, msg)) + if E.is_archive(base_name): + return {"mode": "archive", "dir": inc, "first": base_name} + return {"mode": "file", "path": remote, "name": base_name} + s = srcs[0] + remote = "%s/%s" % (inc, s.name) + setj(job, phase="Enviando %s" % s.name) + E.upload(cfg, remote, s.size, + lambda off: s.stream(off, cfg, cancel, meter), + lambda n: set_sent(n), cancel, lambda msg: jlog(job, msg)) + return {"mode": "file", "path": remote, "name": s.name} + finally: + stop_speed.set() + watch.set() + setj(job, speed=0.0, dl_speed=0.0) + + +# --------------------------------------------------------------------------- +# Alternativa: descomprimir en el PC con 7-Zip y mandar el contenido ya suelto +# --------------------------------------------------------------------------- + +PCX_DIR = os.path.join(DATA_DIR, "pc_extract") + + +def job_where(job): + with LOCK: + w = job.get("where") or SETTINGS.get("extract_where") or "ps5" + return "pc" if w == "pc" else "ps5" + + +def pc_temp_root(cfg): + d = (cfg.get("pc_temp_dir") or "").strip().strip('"') + return d or os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo (temporal)") + + +def _pcx_state(job): + try: + with open(os.path.join(PCX_DIR, job["id"] + ".json"), encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def _pcx_save(job, st): + try: + os.makedirs(PCX_DIR, exist_ok=True) + tmp = os.path.join(PCX_DIR, job["id"] + ".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(st, f) + os.replace(tmp, os.path.join(PCX_DIR, job["id"] + ".json")) + except Exception: + pass + + +def _pcx_forget(job): + try: + os.remove(os.path.join(PCX_DIR, job["id"] + ".json")) + except Exception: + pass + + +def fetch_to_pc(job, srcs, cfg, cancel, meter, folder): + """Baja al PC las partes que son enlaces (las de tu PC se usan tal cual). + Devuelve {nombre: ruta local} y la lista de archivos que hemos creado nosotros.""" + import shutil + local, created = {}, [] + urls = [s for s in srcs if s.kind != "file"] + for s in srcs: + if s.kind == "file": + local[s.name] = s.path + if not urls: + return local, created + if any(s.size < 0 for s in urls): + raise E.PipeError("Para bajar al PC hace falta saber el tamano de cada parte") + os.makedirs(folder, exist_ok=True) + + def have(s): + final = os.path.join(folder, s.name) + if os.path.isfile(final) and os.path.getsize(final) == s.size: + return s.size + part = final + ".part" + return min(os.path.getsize(part), s.size) if os.path.isfile(part) else 0 + + total = sum(s.size for s in urls) + missing = sum(s.size - have(s) for s in urls) + free = shutil.disk_usage(folder).free + if missing + 2 * GiB > free: + raise E.PipeError("No cabe en el PC: hay que bajar %s a %s y solo hay %s libres. Libera espacio, " + "cambia la carpeta temporal en Ajustes o descomprime en la PS5." + % (human(missing), folder, human(free))) + jlog(job, "Bajando %d partes al PC (%s) en %s" % (len(urls), human(total), folder)) + done_before = 0 + for k, s in enumerate(urls, 1): + final = os.path.join(folder, s.name) + created.append(final) + local[s.name] = final + if os.path.isfile(final) and os.path.getsize(final) == s.size: + done_before += s.size + setj(job, sent=done_before) + continue + part = final + ".part" + off = os.path.getsize(part) if os.path.isfile(part) else 0 + if off > s.size: + off = 0 + if off: + jlog(job, "%s: continuando desde %s" % (s.name, human(off))) + setj(job, phase="Bajando al PC: parte %d de %d (%s)" % (k, len(urls), s.name)) + got = off + with open(part, "r+b" if off else "wb") as f: + f.seek(off) + f.truncate() + for data in s.stream(off, cfg, cancel, meter): + f.write(data) + got += len(data) + setj(job, sent=done_before + got) + if got != s.size: + raise E.PipeError("La descarga de %s se quedo corta (%s de %s). Pulsa Reintentar." + % (s.name, human(got), human(s.size))) + os.replace(part, final) + done_before += s.size + jlog(job, "En el PC: %s" % s.name) + return local, created + + +def pc_extract_job(job, srcs, cancel): + cfg = cfg_snapshot() + cfg["_limit_bps"] = live_limit_bps + log = lambda m: jlog(job, m) # noqa: E731 + exe = E.find_7z(cfg.get("sevenzip_path")) + if not exe: + raise E.PipeError("Para descomprimir en el PC hace falta 7-Zip (gratis, 7-zip.org). Instalalo, " + "o pon la ruta de 7z.exe en Ajustes, o elige 'Descomprimir en la PS5'.") + names = [s.name for s in srcs] + folder = os.path.join(pc_temp_root(cfg), E.safe_name(E.set_key(srcs[0].name))) + dl, up = Meter(), Meter() + phase = {"dl": True} + stop_speed = threading.Event() + + def speed_loop(): + while not stop_speed.wait(1): + if phase["dl"]: + setj(job, speed=dl.rate(), dl_speed=0.0) + else: + setj(job, speed=up.rate(), dl_speed=0.0) + threading.Thread(target=speed_loop, daemon=True).start() + bf = E.BFpilot(cfg) + watch = None + try: + # 1) Las partes tienen que estar en el PC (7-Zip necesita poder saltar de una a otra) + if any(s.kind != "file" for s in srcs): + setj(job, status="enviando", total=sum(max(0, s.size) for s in srcs), sent=0, + speed_label="Descarga al PC", sent_label="Bajado al PC", pc_step="bajar") + local, created = fetch_to_pc(job, srcs, cfg, cancel, dl, folder) + first = local[E.first_volume(names)] + + # 2) Leer el indice (con contrasena si hace falta) + setj(job, phase="Leyendo el contenido del comprimido", speed_label="", sent_label="", + pc_step="enviar") + cands = password_candidates(job, cfg) + try: + entries, props = E.list_7z(exe, first) + list_pw = None + except E.PipeError as e: + if not E._pw_error(str(e)): + raise + entries = None + for pw in cands: + try: + entries, props = E.list_7z(exe, first, pw) + list_pw = pw + break + except E.PipeError: + continue + if entries is None: + raise E.PipeError("Ninguna contrasena abre el comprimido. Escribela en el trabajo o en " + "Ajustes y pulsa Reintentar (no se vuelve a descargar).") + files = [e for e in entries if not e["dir"]] + total = sum(e["size"] for e in files) + jlog(job, "Dentro hay %d archivos que ocupan %s descomprimidos%s" + % (len(files), human(total), " (solido)" if props.get("Solid") == "+" else "")) + if list_pw is not None: + pws = [list_pw] + elif E.needs_password(entries): + pws = cands + if not pws: + raise E.PipeError("El comprimido tiene contrasena. Escribela en el trabajo o en Ajustes " + "y pulsa Reintentar.") + else: + pws = [None] + + # 3) Sitio en la PS5: ahora se sabe exacto lo que ocupara todo + staging = "%s/extract/%s" % (WORK_ROOT, job["id"]) + sig = "%s|%d|%d" % (entries[0]["path"] if entries else "", len(entries), total) + st = _pcx_state(job) + skip = int(st.get("done") or 0) if st.get("sig") == sig else 0 + if skip: + try: + if not bf.alive() or not bf.exists(staging): + skip = 0 + except Exception: + skip = 0 + already = sum(e["size"] for e in entries[:skip]) + free = free_space(bf) + margin = margin_bytes() + if free is not None: + need = total - already + if need + margin > free: + raise E.PipeError("No cabe en la PS5: descomprimido ocupa %s, faltan por mandar %s, " + "hay %s libres y dejo %s de margen. Libera espacio y pulsa Reintentar." + % (human(total), human(need), human(free), human(margin))) + jlog(job, "Espacio libre en la PS5: %s (hacen falta %s)" % (human(free), human(need))) + watch = start_space_watch(job, bf, cancel, "el envio") + + # 4) Descomprimir y mandar, archivo a archivo + phase["dl"] = False + setj(job, status="enviando", total=total, sent=already, pc_step="enviar", + phase="Descomprimiendo en el PC y enviando a la PS5") + ordinal = {} + for e in entries: + if not e["dir"]: + ordinal[e["path"]] = len(ordinal) + 1 + if skip: + jlog(job, "Continuo donde iba: %s ya estaban en la PS5" % human(already)) + last = {"v": already, "t": 0.0} + + def progress(v, path): + if v > last["v"]: + up(v - last["v"]) + last["v"] = v + now = time.time() + if now - last["t"] > 0.5: + last["t"] = now + name = path.rsplit("/", 1)[-1] + setj(job, sent=v, phase="Descomprimiendo en el PC y enviando: archivo %d de %d · %s" + % (ordinal.get(path, 0), len(ordinal), name[-60:])) + + # Donde estan las aplicaciones dentro del comprimido (carpeta con sce_sys/param.json) + capture = {} + roots = [] + for e in entries: + low = e["path"].lower() + if not e["dir"] and (low == "sce_sys/param.json" or low.endswith("/sce_sys/param.json")): + root = e["path"][:-len("sce_sys/param.json")].rstrip("/") + if not any(root == r or root.startswith(r + "/") for r in roots if r) and "" not in roots: + roots.append(root) + capture[e["path"]] = None + state = {"done": skip, "files": 0} + + def on_file(n): + state["done"] = n + if not entries[n - 1]["dir"]: + state["files"] += 1 + _pcx_save(job, {"sig": sig, "done": n}) + + for k, pw in enumerate(pws): + start = state["done"] + files_before = state["files"] + if pw: + jlog(job, "Probando contrasena %d de %d" % (k + 1, len(pws))) + try: + E.stream_extract_7z(exe, first, entries, pw, staging, cfg, cancel, log, progress, + on_file=on_file, skip=start, capture=capture) + if pw: + jlog(job, "Contrasena correcta") + break + except E.PipeError as e: + if cancel.is_set(): + raise E.Cancelled() + bad_pw = state["files"] == files_before and len(pws) > 1 and ( + "contrasena" in str(e) or "CRC" in str(e)) + if bad_pw and k + 1 < len(pws): + continue + if bad_pw: + raise E.PipeError("Ninguna contrasena ha funcionado. Escribela en el trabajo o en " + "Ajustes y pulsa Reintentar (no se vuelve a descargar).") + raise + setj(job, sent=total) + jlog(job, "Enviado ya descomprimido: %d archivos, %s, todos comprobados con su CRC" + % (len(files), human(total))) + games = [] + for root in roots: + pj = (root + "/" if root else "") + "sce_sys/param.json" + raw = capture.get(pj) or b"" + tid = (E.title_id(raw.decode("utf-8", "replace")) or E.title_id(root) + or E.title_id(srcs[0].name)) + games.append({"root": root, "tid": tid}) + return {"mode": "pcx", "staging": staging, "hint": E.display_name(srcs[0].name), "games": games, + "pc_files": created, "pc_folder": folder if created else ""} + finally: + stop_speed.set() + if watch: + watch.set() + setj(job, speed=0.0, dl_speed=0.0, speed_label="", sent_label="") + + +def ftp_place(job, info, dest, cfg): + """Coloca lo enviado en modo PC solo con FTP (sin BFpilot): renombrar es instantaneo.""" + staging = info["staging"] + hint = info.get("hint") or job["id"] + ftp = E.Ftp(cfg) + f = ftp.connect() + + def is_dir(path): + try: + f.cwd(path) + f.cwd("/") + return True + except Exception: + return False + try: + if not is_dir(staging): + _pcx_forget(job) + raise E.PipeError("No encuentro en la PS5 lo enviado (%s). Pulsa Reintentar para mandarlo " + "de nuevo." % staging) + games = info.get("games") or [] + moves = [] + if games: + for g in games: + src = staging + ("/" + g["root"] if g["root"] else "") + name = (g["tid"] + "-app") if g.get("tid") else E.safe_name( + g["root"].rsplit("/", 1)[-1] if g["root"] else hint) + moves.append((src, dest + "/" + name)) + else: + moves.append((staging, dest + "/" + E.safe_name(hint))) + 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)) + E.Ftp.mkdirs(f, dest) + f.rename(src, target) + jlog(job, "Colocado: %s" % target) + placed.append(target) + job["_placed_game"] = bool(games) + # carpeta de trabajo vacia (si venia dentro de otra carpeta, quedan restos) + try: + f.rmd(staging) + except Exception: + if is_dir(staging): + jlog(job, "Quedan restos en %s (vacios). Puedes borrarlos con Limpieza." % staging) + return placed + finally: + try: + f.quit() + except Exception: + pass + + +def finish_pc_extract(job, info, bf, dest, cancel, cfg): + staging = info["staging"] + setj(job, status="colocando", phase="Colocando en su sitio") + if not bf.alive(): + jlog(job, "BFpilot no esta cargado: lo coloco por FTP") + placed = ftp_place(job, info, dest, cfg) + else: + if not bf.exists(staging): + _pcx_forget(job) + raise E.PipeError("No encuentro en la PS5 lo enviado (%s). Pulsa Reintentar para mandarlo " + "de nuevo." % staging) + placed = place(job, bf, staging, dest, cancel, info.get("hint") or job["id"]) + for target in placed: + fix_game_perms(job, bf, target, cancel) + if bf.exists(staging): + try: + bf.delete(staging, cancel) + except Exception as e: + jlog(job, "No pude borrar %s: %s" % (staging, e)) + _pcx_forget(job) + pc_files = info.get("pc_files") or [] + if pc_files and cfg.get("delete_archives", True): + for pth in pc_files: + try: + os.remove(pth) + except Exception: + pass + try: + os.rmdir(info.get("pc_folder") or "") + except Exception: + pass + jlog(job, "Borradas del PC las partes descargadas") + elif pc_files: + jlog(job, "Las partes descargadas se quedan en %s" % info.get("pc_folder")) + return placed + + +# --------------------------------------------------------------------------- +# Fase 3: extraer en la PS5 y colocar el resultado +# --------------------------------------------------------------------------- + +def wait_bfpilot(job, bf, cancel): + first = True + while True: + v = bf.alive() + if v: + return v + if first: + jlog(job, "BFpilot no responde. Cargalo en la PS5 desde Payload Manager; sigo esperando.") + first = False + setj(job, phase="Esperando a BFpilot (cargalo desde Payload Manager)") + if cancel.wait(10): + raise E.Cancelled() + + +def find_payloads(bf, root, depth=4): + """Busca aplicaciones (carpetas con sce_sys), imagenes y pkg dentro de lo extraido.""" + found = [] + + def walk(path, d): + entries = bf.listdir(path) + names = {e["name"]: e for e in entries} + if "sce_sys" in names and names["sce_sys"].get("dir"): + sub = {e["name"] for e in bf.listdir(path + "/sce_sys")} + if "param.json" in sub or "param.sfo" in sub: + found.append(("game", path)) + return + for e in entries: + full = path + "/" + e["name"] + low = e["name"].lower() + if e.get("dir"): + if d < depth and not low.startswith(".bfpilot-extracting"): + walk(full, d + 1) + elif low.endswith(E.IMAGE_EXTS): + found.append(("image", full)) + elif low.endswith(".pkg"): + found.append(("pkg", full)) + + walk(root, 0) + return found + + +def game_bases(bf): + """Carpetas que escanea ShadowMountPlus: interno + USB/externos montados.""" + bases = ["/data/homebrew"] + pl = bf.places() or {} + for m in (pl.get("mounts") or []) + (pl.get("places") or []): + path = (m.get("path") or "").rstrip("/") + if re.match(r"^/mnt/(usb|ext)\d+$", path): + for b in (path + "/homebrew", path): + if b not in bases: + bases.append(b) + return bases + + +def fix_all_perms(): + """Repasa las aplicaciones en carpeta de /data/homebrew (y USB) y les pone permisos 777.""" + bf = E.BFpilot(cfg_snapshot()) + if not bf.alive(): + raise E.PipeError("Hace falta BFpilot cargado para revisar los permisos") + out = [] + for base in game_bases(bf): + try: + ents = bf.listdir(base) + except Exception: + continue + for e in ents: + if not e.get("dir"): + continue + path = base + "/" + e["name"] + try: + changed, seen, notable = bf.fix_perms(path) + out.append({"path": path, "changed": changed, "seen": seen + 1, + "eboot": ["%o" % v for v in notable.values()][:1]}) + except Exception as ex: + out.append({"path": path, "error": str(ex)}) + return out + + +def find_duplicates(bf, tid, exclude=None): + """Rutas que ya contienen ese Title ID (carpetas o imagenes).""" + if not tid: + return [] + out = [] + for base in game_bases(bf): + try: + entries = bf.listdir(base) + except Exception: + continue + for e in entries: + if tid.upper() in e["name"].upper(): + p = "%s/%s" % (base, e["name"]) + if p != exclude: + out.append(p) + return out + + +def warn_duplicates(job, bf, tid, exclude=None, when=""): + try: + dups = find_duplicates(bf, tid, exclude) + except Exception: + return + if dups: + msg = ("Ojo: ya tienes %s en %s. Dos copias con el mismo Title ID pueden liar a " + "ShadowMountPlus (deja solo una)." % (tid, ", ".join(dups[:4]))) + jlog(job, msg + when) + with LOCK: + job["warning"] = msg + + +def place(job, bf, staging, dest, cancel, hint_name): + found = find_payloads(bf, staging) + placed = [] + if not found: + jlog(job, "No encontre sce_sys ni imagenes: dejo todo junto en una carpeta") + found = [("folder", staging)] + for kind, path in found: + if cancel.is_set(): + raise E.Cancelled() + name = posixpath.basename(path) + if kind == "game": + # Siempre TITLEID-app, venga suelto o dentro de una carpeta del RAR + tid = (E.title_id(bf.read_text(path + "/sce_sys/param.json")) + or E.title_id(name) or E.title_id(hint_name)) + if tid: + name = tid + "-app" + elif path == staging: + name = E.safe_name(hint_name) + elif path == staging: + name = E.safe_name(hint_name) + target_dir = "/data/pkg" if kind == "pkg" else dest + target = target_dir + "/" + name + if kind in ("game", "image"): + warn_duplicates(job, bf, E.title_id(name), exclude=target) + 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)) + bf.mkdir(target_dir) + bf.rename(path, target) + jlog(job, "Colocado: %s" % target) + placed.append(target) + if kind in ("game", "image"): + job["_placed_game"] = True + return placed + + +def fix_game_perms(job, bf, target, cancel=None): + """Tras descomprimir con BFpilot: permisos 777 (si no, la PS5 no la arranca).""" + try: + if not bf.exists(target): + return + setj(job, phase="Arreglando permisos de %s" % posixpath.basename(target)) + changed, seen, notable = bf.fix_perms(target, cancel, log=lambda m: jlog(job, m)) + old = ", ".join("%s tenia %o" % (k, v) for k, v in list(notable.items())[:2]) + if changed: + jlog(job, "Permisos arreglados (777) en %d de %d elementos%s" % (changed, seen + 1, + (": " + old) if old else "")) + else: + jlog(job, "Permisos ya correctos") + except E.Cancelled: + raise + except Exception as e: + jlog(job, "AVISO: no pude poner los permisos de %s (%s). Si no arranca, en FileZilla: " + "clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios." % (target, e)) + + +def password_candidates(job, cfg): + cands = [] + for p in ([job.get("password")] + list(cfg.get("passwords") or [])): + if p and p not in cands: + cands.append(p) + if cfg.get("guess_passwords", True): + for p in E.password_guesses([s["name"] for s in job["sources"]]): + if p not in cands: + cands.append(p) + return cands + + +def extract_job(job, info, cancel): + bf = E.BFpilot(cfg_snapshot()) + watch = start_space_watch(job, bf, cancel, "la descompresion") + try: + return _extract_job(job, info, cancel) + finally: + watch.set() + + +def _extract_job(job, info, cancel): + cfg = cfg_snapshot() + bf = E.BFpilot(cfg) + dest = job["dest"] or cfg["dest"] + if info["mode"] == "pcx": + return finish_pc_extract(job, info, bf, dest, cancel, cfg) + wait_bfpilot(job, bf, cancel) + staging = "%s/extract/%s" % (WORK_ROOT, job["id"]) + + if info["mode"] == "file": + name = info["name"] + low = name.lower() + 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"])) + bf.mkdir(target_dir) + bf.rename(info["path"], target) + jlog(job, "Colocado: %s" % target) + try: + bf.delete(info["path"].rsplit("/", 1)[0], cancel) + except Exception: + pass + if low.endswith(".pkg"): + jlog(job, "Es un .pkg: instalalo con ps5upload (Install Package) desde /data/pkg.") + return [target] + + src = info["dir"] + "/" + info["first"] + cands = password_candidates(job, cfg) + [""] + bf.mkdir(WORK_ROOT + "/extract") + if bf.exists(staging): + # BFpilot solo crea esta carpeta cuando la extraccion termina bien + jlog(job, "Ya estaba descomprimido de antes; paso a colocarlo") + cands = [] + else: + for e in bf.listdir(WORK_ROOT + "/extract"): + if e["name"].startswith(job["id"] + ".bfpilot-extracting"): + jlog(job, "Borrando restos de un intento anterior: %s" % e["name"]) + bf.delete(WORK_ROOT + "/extract/" + e["name"], cancel) + + live = {"sig": None, "t": time.time(), "warned": False} + guard = {"t": 0.0} + + def check_runaway(st): + """Mide cuanto lleva escrito BFpilot y cuanto queda libre, para avisar a tiempo.""" + total = int(st.get("totalBytes") or 0) + if total <= 0 or time.time() - guard["t"] < 10: + return + guard["t"] = time.time() + try: + # Solo la carpeta temporal de ESTA descompresion (BFpilot la dice en "stage"). + # Antes se sumaban restos de intentos anteriores y saltaba en falso. + stage = st.get("stage") or "" + if not stage.startswith(WORK_ROOT + "/extract/") or ".bfpilot-extracting" not in stage: + return + size = bf.du(stage) or 0 + free = free_space(bf) + guard["size"], guard["free"] = size, free + # Prevision: el progreso de BFpilot cuenta lo LEIDO del RAR, no lo escrito. Un volcado en + # carpeta va sin comprimir y puede ocupar varias veces el RAR (visto: 35 GB -> 145 GB). + # Regla prudente: hasta ~5 veces lo que ocupa el RAR. + guard["fits"] = None + if free is not None: + worst = total * 5 + guard["final"] = max(worst, size) + # lo que aun puede crecer (al menos 10 GB de colchon aunque ya pase del estimado) + guard["fits"] = (free - margin_bytes()) > max(worst - size, 10 * GiB) + except Exception: + pass + + def on_status(st): + pct = int(st.get("percent") or 0) + txt = [] + if st.get("totalBytes"): + # OJO: BFpilot cuenta lo LEIDO del RAR, no el tamano final (que puede ser mucho mayor) + txt.append("leido del RAR %s de %s" % (human(st.get("bytesWritten") or 0), human(st["totalBytes"]))) + if st.get("averageMBps"): + txt.append("%.0f MB/s" % float(st["averageMBps"])) + # Senal de vida: el porcentaje a veces solo sube al acabar cada archivo grande, + # pero lo leido/escrito y la CPU de BFpilot cambian aunque el % no se mueva. + sig = (st.get("bytesWritten"), st.get("inputBytes"), st.get("outputBytes"), + st.get("cpuTotalMs"), st.get("filesDone"), st.get("current")) + now = time.time() + if sig != live["sig"]: + live.update(sig=sig, t=now) + if live["warned"]: + jlog(job, "La descompresion vuelve a avanzar") + live["warned"] = False + if guard.get("size") is not None: + t = "descomprimido %s" % human(guard["size"]) + if guard.get("free") is not None: + t += " · libre %s" % human(guard["free"]) + if guard.get("fits") is False: + t += " · justo de espacio (en el peor caso podria llegar a ~%s)" % human(guard.get("final", 0)) + elif guard.get("fits"): + t += " · hay sitio de sobra" + txt.append(t) + idle = now - live["t"] + if st.get("cpuUtilPercent") is not None and idle < 30: + txt.append("CPU %d%%" % float(st["cpuUtilPercent"])) + if idle >= 300: + txt.append("sin cambios desde hace %d min" % (idle // 60)) + if not live["warned"]: + jlog(job, "Aviso: la descompresion no da senales de vida desde hace 5 min. " + "Mira en BFpilot (puerto 5905) si sigue 'running' antes de cancelar.") + live["warned"] = True + elif st.get("current"): + txt.append(str(st["current"])[-60:]) + setj(job, extract_pct=pct, extract_text=" · ".join(txt)) + check_runaway(st) + if guard.get("fits") is False and not guard.get("fit_logged"): + guard["fit_logged"] = True + jlog(job, "Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR (~%s) y quedan %s " + "libres. Puede bastar, pero si el espacio baja del margen lo paro. Si puedes, " + "libera espacio." % (human(guard.get("final", 0)), human(guard.get("free") or 0))) + + last_err = None + final_st = None + for i, pw in enumerate(cands): + label = ("contrasena %d de %d" % (i + 1, len(cands) - 1)) if pw else "sin contrasena" + setj(job, status="extrayendo", phase="Descomprimiendo en la PS5 (%s)" % label, + extract_pct=0, extract_text="") + jlog(job, "Descomprimiendo %s (%s)" % (info["first"], label)) + try: + final_st = bf.extract(src, staging, pw, cancel, on_status, + threads=cfg_snapshot().get("extract_threads", 0)) + if pw: + jlog(job, "Contrasena correcta") + last_err = None + break + except E.PipeError as e: + last_err = e + jlog(job, "Fallo: %s" % e) + if bf.exists(staging): + bf.delete(staging, cancel) + if not E.is_password_error(str(e)): + break + if last_err is not None: + msg = str(last_err).lower() + if "aes" in msg: + raise E.PipeError("Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo (solo el cifrado " + "ZIP clasico). Los archivos ya estan en la PS5, en %s. Puedes bajarlos al PC, " + "extraerlos con 7-Zip y subir la carpeta." % info["dir"]) + if "unsupported archive type" in msg and re.search(r"\.z\d{2}$|\.zip$", info["first"], re.I): + raise E.PipeError("BFpilot no abre ZIP partidos (.z01, .z02...). Uno los RAR/7z partidos, " + "pero este formato no. Los archivos estan en %s." % info["dir"]) + if E.is_password_error(str(last_err)): + raise E.PipeError("Ninguna contrasena ha funcionado. Escribela en el trabajo o en Ajustes " + "y pulsa Reintentar (no hace falta volver a descargar).") + raise last_err + + got = bf.du(staging) + if got is not None: + jlog(job, "Lo descomprimido ocupa %s" % human(got)) + + setj(job, status="colocando", phase="Colocando en su sitio", extract_pct=100) + hint = info["first"][:len(E.set_key(info["first"]))] # mismo nombre, sin cambiar mayusculas + placed = place(job, bf, staging, dest, cancel, hint) + for target in placed: + fix_game_perms(job, bf, target, cancel) + for leftover in (staging,): + if bf.exists(leftover): + try: + bf.delete(leftover, cancel) + except Exception as e: + jlog(job, "No pude borrar %s: %s" % (leftover, e)) + if cfg.get("delete_archives", True): + jlog(job, "Borrando los comprimidos de la PS5") + try: + bf.delete(info["dir"], cancel) + except Exception as e: + jlog(job, "No pude borrar %s: %s" % (info["dir"], e)) + else: + jlog(job, "Los comprimidos se quedan en %s" % info["dir"]) + return placed + + +# --------------------------------------------------------------------------- +# Hilos de trabajo: uno envia, otro descomprime (pueden ir a la vez) +# --------------------------------------------------------------------------- + +def stopped(job): + """Un trabajo se ha parado: por falta de espacio, por pausa o porque lo cancelaste.""" + with LOCK: + reason = job.pop("_stop", None) + paused = job.pop("_pause", False) + if reason: + setj(job, status="error", error=reason, phase="Error") + jlog(job, "ERROR: %s" % reason) + elif paused: + setj(job, status="pausado", phase="En pausa (pulsa Reanudar para seguir)") + jlog(job, "En pausa") + else: + setj(job, status="cancelado", phase="Cancelado") + + +def transfer_worker(): + while True: + with WAKE: + job = None + while job is None: + for j in JOBS: + if j["status"] == "pendiente": + job = j + break + if job is None: + WAKE.wait() + cancel = CANCELS.setdefault(job["id"], threading.Event()) + cancel.clear() + job["status"] = "comprobando" + try: + srcs = probe_job(job, cancel) + sets = split_into_sets(srcs) + if len(sets) > 1: + jlog(job, "Detectados %d archivos distintos: los separo en varios trabajos" % len(sets)) + with LOCK: + idx = JOBS.index(job) + for k, extra in enumerate(sets[1:], 1): + nj = new_job([s.original for s in extra], job["password"], job["dest"], + where=job.get("where")) + nj["sources"] = describe(extra) + nj["title"] = E.display_name(extra[0].name) + JOBS.insert(idx + k, nj) + job["lines"] = [s.original for s in sets[0]] + srcs = sets[0] + setj(job, sources=describe(srcs), title=job["title"] or E.display_name(srcs[0].name)) + save_jobs() + info = transfer_job(job, srcs, cancel) + setj(job, info=info, status="en cola para descomprimir", + phase="Enviado. Esperando turno para descomprimir") + jlog(job, "Envio completo") + with WAKE: + EXTRACT_Q.append(job["id"]) + WAKE.notify_all() + except E.Cancelled: + stopped(job) + except Exception as e: + msg = str(e) + if "perdida" in msg: + free = free_space(E.BFpilot(cfg_snapshot())) + if free is not None and free < margin_bytes(): + msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free) + setj(job, status="error", error=msg, phase="Error") + jlog(job, "ERROR: %s" % msg) + traceback.print_exc() + save_jobs() + + +def extract_worker(): + while True: + with WAKE: + while not EXTRACT_Q: + WAKE.wait() + job = find(EXTRACT_Q.pop(0)) + if job is None or job["status"] != "en cola para descomprimir": + continue + cancel = CANCELS.setdefault(job["id"], threading.Event()) + try: + placed = extract_job(job, job["info"], cancel) + is_game = (job.pop("_placed_game", False) + or any(p.endswith("-app") or p.lower().endswith(E.IMAGE_EXTS) for p in placed)) \ + and (job.get("dest", "").startswith("/data/homebrew") or "/homebrew" in job.get("dest", "")) + setj(job, status="hecho", result=placed, extract_text="", + phase=("Listo. ShadowMountPlus lo anadira al inicio en unos segundos" if is_game + else "Listo. Copiado en la PS5")) + jlog(job, "Terminado") + except E.Cancelled: + stopped(job) + except Exception as e: + msg = str(e) + if "perdida" in msg: + free = free_space(E.BFpilot(cfg_snapshot())) + if free is not None and free < margin_bytes(): + msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free) + setj(job, status="error", error=msg, phase="Error") + jlog(job, "ERROR: %s" % msg) + traceback.print_exc() + save_jobs() + + +# --------------------------------------------------------------------------- +# Servidor web local +# --------------------------------------------------------------------------- + +TOKEN = secrets.token_urlsafe(18) + + +def test_connection(cfg): + out = {"ftp": None, "bf": None, "free": None} + try: + f = E.Ftp(cfg).connect() + f.quit() + out["ftp"] = "ok" + except Exception as e: + out["ftp"] = "error: %s" % e + bf = E.BFpilot(cfg) + v = bf.alive() + out["bf"] = ("ok (%s)" % v) if v else "no responde (cargalo desde Payload Manager)" + if v: + try: + st = bf._req("GET", "/api/fs/stat", {"path": "/data"}) + out["free"] = human(int(st.get("availableBytes") or 0)) + except Exception: + pass + return out + + +# --------------------------------------------------------------------------- +# Limpieza de restos en /data/ps5directo +# --------------------------------------------------------------------------- + +def _job_dirs(): + """Carpetas de trabajo que todavia usa algun trabajo de la cola -> titulo.""" + used = {} + with LOCK: + jobs = [dict(j) for j in JOBS if j["status"] != "hecho"] + for j in jobs: + title = j.get("title") or "trabajo" + srcs = j.get("sources") or [] + if srcs: + used["%s/incoming/%s" % (WORK_ROOT, E.safe_name(E.set_key(srcs[0]["name"])))] = title + used["%s/incoming/%s" % (WORK_ROOT, j["id"])] = title + used["%s/extract/%s" % (WORK_ROOT, j["id"])] = title + return used + + +def cleanup_scan(): + bf = E.BFpilot(cfg_snapshot()) + if not bf.alive(): + raise E.PipeError("BFpilot no responde: cargalo para poder buscar restos") + used = _job_dirs() + items = [] + + def add(path, label): + size = bf.du(path) + owner = None + for u, title in used.items(): + if path == u or path.startswith(u + ".") or path.startswith(u + "/"): + owner = title + items.append({"path": path, "label": label, "size": size or 0, "in_use": owner}) + + try: + top = bf.listdir(WORK_ROOT) + except Exception: + top = [] + for e in top: + n = e["name"] + if n in ("incoming", "extract") and e.get("dir"): + try: + sub = bf.listdir("%s/%s" % (WORK_ROOT, n)) + except Exception: + sub = [] + for x in sub: + add("%s/%s/%s" % (WORK_ROOT, n, x["name"]), + ("Partes enviadas: " if n == "incoming" else "Descompresion a medias: ") + x["name"]) + else: + add("%s/%s" % (WORK_ROOT, n), "Otro: " + n) + items.sort(key=lambda x: -x["size"]) + return items + + +def cleanup_delete(paths): + with LOCK: + if any(j["status"] in ("extrayendo", "colocando") for j in JOBS): + raise E.PipeError("Espera a que termine la descompresion: BFpilot esta ocupado") + bf = E.BFpilot(cfg_snapshot()) + done = [] + for p in paths: + p = posixpath.normpath(str(p)) + if not p.startswith(WORK_ROOT + "/") or ".." in p: + continue # solo dentro de la carpeta de trabajo + bf.delete(p) + done.append(p) + return done + + +# --------------------------------------------------------------------------- +# Test de velocidad: descarga sola, envio solo y los dos a la vez +# --------------------------------------------------------------------------- + +SPEEDTEST = {"running": False, "result": None} + + +def _dl_test(url, cfg, seconds, stop): + src = E.UrlSource(url).probe() + got = [0] + if not src.ranges or src.size <= 0: + gen = E._single(src.url, stop, lambda n: None) + else: + start = src.size // 3 # mitad del archivo: no siempre el principio cacheado + gen = E._ranged(src.url, start, src.size, cfg, stop, lambda n: None) + t0 = time.time() + try: + for data in gen: + got[0] += len(data) + if time.time() - t0 >= seconds or stop.is_set(): + break + finally: + try: + gen.close() + except Exception: + pass + return got[0] / max(0.5, time.time() - t0) + + +def _up_test(cfg, seconds, stop): + path = WORK_ROOT + "/speedtest.tmp" + f = E.Ftp(cfg).connect() + try: + E.Ftp.mkdirs(f, WORK_ROOT) + conn = f.transfercmd("STOR " + path) + block = b"\0" * E.MiB + sent = 0 + t0 = time.time() + with conn: + while time.time() - t0 < seconds and not stop.is_set(): + conn.sendall(block) + sent += len(block) + el = time.time() - t0 + try: + f.voidresp() + except Exception: + pass + try: + f.delete(path) + except Exception: + pass + return sent / max(0.5, el) + finally: + try: + f.quit() + except Exception: + pass + + +def run_speedtest(url, seconds=10): + """Mide por separado para saber de quien es la culpa cuando va lento.""" + cfg = cfg_snapshot() + res = {"url": bool(url), "dl": None, "up": None, "both_dl": None, "both_up": None, "pipe": None, + "error": None, "verdict": ""} + stop = threading.Event() + try: + if url: + res["dl"] = _dl_test(url, cfg, seconds, stop) + res["up"] = _up_test(cfg, seconds, stop) + if url: + box = {} + th = threading.Thread(target=lambda: box.update(d=_dl_test(url, cfg, seconds, stop))) + th.start() + box["u"] = _up_test(cfg, seconds, stop) + th.join() + res["both_dl"], res["both_up"] = box.get("d"), box["u"] + except Exception as e: + res["error"] = str(e) + mb = 1024 * 1024 + dl, up, both = res["dl"], res["up"], res["both_up"] + v = [] + if up is not None: + v.append("PC -> PS5 solo: %.1f MB/s." % (up / mb)) + if up < 6 * mb: + v.append("Es bajo: el cuello es el Wi-Fi/cable entre PC y PS5 o el FTP de la consola.") + if dl is not None: + v.append("Descarga sola desde ese enlace: %.1f MB/s." % (dl / mb)) + if dl < 5 * mb: + v.append("Es bajo: el alojamiento te esta dando poca velocidad ahora mismo " + "(limite de cuenta gratis, enlace castigado o servidor lento). No es tu red.") + if dl and up and both is not None and res["both_dl"] is not None: + ideal = 1 / (1 / dl + 1 / up) + pipe = min(both, res["both_dl"]) # lo que llegaria de verdad descargando y enviando + res["pipe"] = pipe + v.append("Las dos cosas a la vez: en la practica llegarian unos %.1f MB/s a la PS5. Con una " + "sola radio Wi-Fi lo maximo esperable seria unos %.1f MB/s." % (pipe / mb, ideal / mb)) + if dl < 5 * mb: + v.append("Aqui manda el alojamiento: mientras de tan poca velocidad, nada de tu lado " + "lo va a mejorar.") + elif pipe < 0.6 * ideal: + v.append("Estan compitiendo peor de lo normal: prueba el limite de velocidad en Ajustes " + "un poco por debajo de esa cifra, o un cable de red en el PC.") + else: + v.append("Es lo normal para una sola radio: compartir el aire tiene ese coste. " + "Solo un cable de red en el PC lo mejoraria de verdad.") + res["verdict"] = " ".join(v) + for k in ("dl", "up", "both_dl", "both_up", "pipe"): + if res[k] is not None: + res[k] = round(res[k] / mb, 2) + return res + + +def _speedtest_thread(url): + try: + SPEEDTEST["result"] = run_speedtest(url) + finally: + SPEEDTEST["running"] = False + + +def health_check(): + """Revision completa: que funciona, que no y que hacer.""" + cfg = cfg_snapshot() + out = [] + + def add(name, ok, detail, fix=""): + out.append({"name": name, "ok": ok, "detail": detail, "fix": fix}) + + f = None + try: + f = E.Ftp(cfg).connect() + add("FTP de la PS5", True, "%s:%s responde" % (cfg["ps5_ip"], cfg["ftp_port"])) + try: + E.Ftp.mkdirs(f, WORK_ROOT) + path = WORK_ROOT + "/prueba_escritura.tmp" + import io + f.storbinary("STOR " + path, io.BytesIO(b"x" * 65536)) + ok = E.Ftp.size(f, path) == 65536 + try: + f.delete(path) + except Exception: + pass + add("Escribir en la PS5", ok, "Se puede escribir en %s" % WORK_ROOT if ok else "La prueba no se guardo bien", + "" if ok else "Revisa que el servidor FTP permita escribir en /data") + except Exception as e: + 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), + "Comprueba la IP en Ajustes y que el payload FTP este cargado (puerto %s)" % cfg["ftp_port"]) + finally: + try: + f and f.quit() + except Exception: + pass + exe = E.find_7z(cfg.get("sevenzip_path")) + where = cfg.get("extract_where") or "ps5" + add("7-Zip en el PC", bool(exe) or where != "pc", exe or "no instalado", + "" if exe else ("Hace falta para 'Descomprimir en el PC': instalalo desde 7-zip.org" + if where == "pc" else "Solo hace falta si eliges descomprimir en el PC")) + bf = E.BFpilot(cfg) + v = bf.alive() + add("BFpilot", bool(v), ("version %s" % v) if v else "no responde en el puerto %s" % cfg["bf_port"], + "" if v else "Cargalo desde Payload Manager: sin el no se puede descomprimir") + if v: + try: + sup = bf._req("GET", "/api/fs/archive/support") + add("Descompresion", True, "Formatos: %s" % ", ".join(sup.get("supported") or [])) + except Exception as e: + add("Descompresion", False, str(e), "Actualiza BFpilot a la ultima version") + free = free_space(bf) + if free is not None: + margin = margin_bytes() + ok = free > margin + 10 * GiB + add("Espacio libre", ok, "%s libres (margen %s)" % (human(free), human(margin)), + "" if ok else "Queda poco: borra restos (Limpieza) o algo que no uses") + try: + st = bf._req("GET", "/api/fs/archive/status") + if st.get("state") in ("running", "prepared"): + add("Tarea en BFpilot", True, "Ahora mismo esta descomprimiendo: %s%%" % st.get("percent", "?")) + except Exception: + pass + return out + + +# --------------------------------------------------------------------------- +# Actualizaciones: desde un .zip o desde GitHub +# --------------------------------------------------------------------------- + +UPDATE_EXTS = (".py", ".html", ".txt", ".bat", ".vbs", ".ico", ".js", ".json", ".png", ".css", ".md", ".svg") + + +def _ver_tuple(v): + return tuple(int(x) for x in re.findall(r"\d+", v or "0")[:4]) + + +def install_zip(data): + """Instala una version nueva desde los bytes de un .zip. Devuelve la version instalada.""" + import io + import zipfile + if busy(): + raise E.PipeError("Hay trabajos en marcha: espera a que terminen o pausalos antes de actualizar") + try: + z = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile: + raise E.PipeError("Ese archivo no es un .zip valido") + names = z.namelist() + roots = [n[:-len("app.py")] for n in names if n.endswith("app.py") and (n[:-len("app.py")] + "engine.py") in names] + if not roots: + raise E.PipeError("Ese .zip no es de PS5 Directo (no trae app.py y engine.py)") + root = min(roots, key=len) + m = re.search(r'VERSION = "([^"]+)"', z.read(root + "app.py").decode("utf-8", "replace")) + if not m: + raise E.PipeError("No encuentro la version dentro del .zip") + new_ver = m.group(1) + files = [] + for n in names: + if not n.startswith(root) or n.endswith("/"): + continue + rel = n[len(root):] + parts = rel.replace("\\", "/").split("/") + if not rel or rel.startswith("/") or ".." in parts or ":" in rel: + continue + if not rel.lower().endswith(UPDATE_EXTS): + continue + files.append((n, rel)) + # copia de seguridad de lo que se va a reemplazar + backup = os.path.join(HERE, ".backup", VERSION) + for _, rel in files: + cur = os.path.join(HERE, *rel.split("/")) + if os.path.isfile(cur): + dst = os.path.join(backup, *rel.split("/")) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(cur, "rb") as fi, open(dst, "wb") as fo: + fo.write(fi.read()) + for n, rel in files: + dst = os.path.join(HERE, *rel.split("/")) + os.makedirs(os.path.dirname(dst), exist_ok=True) + tmp = dst + ".nuevo" + with open(tmp, "wb") as fo: + fo.write(z.read(n)) + os.replace(tmp, dst) + return new_ver + + +def restart_soon(): + """Arranca la version nueva y cierra esta (la nueva espera a que esta se cierre).""" + import subprocess + args = [sys.executable, os.path.join(HERE, "app.py"), "--after-update"] + kw = {"cwd": HERE} + if os.name == "nt": + kw["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP + def go(): + time.sleep(0.8) + try: + subprocess.Popen(args, **kw) + finally: + os._exit(0) + threading.Thread(target=go, daemon=True).start() + + +def update_repo(): + with LOCK: + return (SETTINGS.get("update_repo") or DEFAULT_REPO or "").strip() + + +def github_latest(repo): + """(version, url_del_zip, notas) de la ultima release de GitHub.""" + repo = (repo or "").strip().strip("/") + if not re.match(r"^[\w.-]+/[\w.-]+$", repo): + raise E.PipeError("Pon el repositorio como usuario/nombre en Ajustes") + req = urllib.request.Request("https://api.github.com/repos/%s/releases/latest" % repo, + headers={"User-Agent": "PS5Directo/" + VERSION, + "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=20) as r: + j = json.load(r) + url = None + for a in j.get("assets") or []: + if a.get("name", "").lower().endswith(".zip"): + url = a.get("browser_download_url") + break + url = url or j.get("zipball_url") + return (j.get("tag_name") or "").lstrip("vV"), url, (j.get("body") or "")[:2000] + + +# --------------------------------------------------------------------------- +# Enlaces capturados por la extension del navegador +# --------------------------------------------------------------------------- + +INBOX = [] # [{"url":..., "name":..., "t":...}] + + +class Handler(BaseHTTPRequestHandler): + server_version = "PS5Directo" + + def log_message(self, *a): + pass + + def _send(self, code, body, ctype="application/json; charset=utf-8"): + if isinstance(body, (dict, list)): + body = json.dumps(body, ensure_ascii=False).encode("utf-8") + elif isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(body) + + def _auth(self): + if self.headers.get("X-Token") != TOKEN: + self._send(403, {"ok": False, "error": "token"}) + return False + return True + + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + if n > 5 * 1024 * 1024: + return {} + raw = self.rfile.read(n) if n else b"{}" + try: + return json.loads(raw.decode("utf-8")) + except ValueError: + return {} + + def do_GET(self): + if self.path == "/logo.svg": + with open(os.path.join(HERE, "web", "logo.svg"), "rb") as f: + return self._send(200, f.read(), "image/svg+xml") + if self.path == "/favicon.ico": + with open(os.path.join(HERE, "web", "ps5directo.ico"), "rb") as f: + return self._send(200, f.read(), "image/x-icon") + if self.path in ("/", "/index.html") or self.path.startswith("/?"): + with open(os.path.join(HERE, "web", "index.html"), "rb") as f: + return self._send(200, f.read(), "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 == "/api/state": + if not self._auth(): + return + if self.headers.get("X-UI"): + UI_SEEN["t"] = time.time() + with LOCK: + jobs = [] + for j in JOBS: + d = {k: v for k, v in j.items() if k not in ("lines", "info", "password")} + d["has_password"] = bool(j.get("password")) + d["pcx"] = (j.get("info") or {}).get("mode") == "pcx" + jobs.append(d) + st = {"ok": True, "version": VERSION, "settings": dict(SETTINGS), "jobs": jobs, + "inbox": len(INBOX), "repo": SETTINGS.get("update_repo") or DEFAULT_REPO} + st["settings"].pop("ftp_pass", None) + return self._send(200, st) + self._send(404, {"ok": False}) + + def _from_extension(self): + o = self.headers.get("Origin") or "" + return o.startswith("chrome-extension://") or o.startswith("moz-extension://") \ + or o.startswith("extension://") + + def do_POST(self): + if self.path == "/api/ext/capture": + if not self._from_extension(): + return self._send(403, {"ok": False, "error": "solo desde la extension"}) + b = self._body() + url = str(b.get("url") or "").strip() + if not re.match(r"^https?://", url, re.I) or len(url) > 4000: + return self._send(400, {"ok": False, "error": "enlace no valido"}) + with LOCK: + if not any(x["url"] == url for x in INBOX): + INBOX.append({"url": url, "name": str(b.get("name") or "")[:300], "t": time.time()}) + del INBOX[:-200] + return self._send(200, {"ok": True}) + if not self._auth(): + return + b = self._body() + p = self.path + if p == "/api/open_ext_folder": + folder = os.path.join(HERE, "extension") + try: + if os.name == "nt": + os.startfile(folder) + return self._send(200, {"ok": True, "path": folder}) + except Exception as e: + return self._send(200, {"ok": False, "path": folder, "error": str(e)}) + if p == "/api/inbox/take": + with LOCK: + items = list(INBOX) + INBOX[:] = [] + return self._send(200, {"ok": True, "items": items}) + if p == "/api/update/zip": + import base64 + try: + ver = install_zip(base64.b64decode(b.get("data") or "")) + except Exception as e: + return self._send(400, {"ok": False, "error": str(e)}) + restart_soon() + return self._send(200, {"ok": True, "version": ver}) + if p == "/api/update/check": + try: + ver, url, notes = github_latest(update_repo()) + except Exception as e: + return self._send(200, {"ok": False, "error": str(e)}) + return self._send(200, {"ok": True, "current": VERSION, "latest": ver, "notes": notes, + "newer": _ver_tuple(ver) > _ver_tuple(VERSION)}) + if p == "/api/update/github": + try: + ver, url, _ = github_latest(update_repo()) + if not url: + raise E.PipeError("Esa release no tiene un .zip") + req = urllib.request.Request(url, headers={"User-Agent": "PS5Directo/" + VERSION}) + with urllib.request.urlopen(req, timeout=120) as r: + data = r.read(50 * 1024 * 1024) + ver = install_zip(data) + except Exception as e: + return self._send(400, {"ok": False, "error": str(e)}) + restart_soon() + return self._send(200, {"ok": True, "version": ver}) + if p == "/api/settings": + with LOCK: + for k, v in b.items(): + if k in DEFAULTS: + if isinstance(DEFAULTS[k], bool): + v = bool(v) + elif isinstance(DEFAULTS[k], int): + try: + v = int(v) + except (TypeError, ValueError): + continue + elif isinstance(DEFAULTS[k], list): + v = [x.strip() for x in (v or []) if str(x).strip()] + SETTINGS[k] = v + save_settings() + return self._send(200, {"ok": True}) + if p == "/api/locate": + files = b.get("files") or [] + return self._send(200, {"ok": True, "found": locate_files(files)}) + if p == "/api/health": + return self._send(200, {"ok": True, "checks": health_check()}) + if p == "/api/places": + bf = E.BFpilot(cfg_snapshot()) + try: + bases = game_bases(bf) if bf.alive() else ["/data/homebrew"] + except Exception: + bases = ["/data/homebrew"] + return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]}) + if p == "/api/fixperms": + try: + return self._send(200, {"ok": True, "results": fix_all_perms()}) + except Exception as e: + return self._send(500, {"ok": False, "error": str(e)}) + if p == "/api/cleanup/scan": + try: + return self._send(200, {"ok": True, "items": cleanup_scan()}) + except Exception as e: + return self._send(500, {"ok": False, "error": str(e)}) + if p == "/api/cleanup/delete": + try: + return self._send(200, {"ok": True, "deleted": cleanup_delete(b.get("paths") or [])}) + except Exception as e: + return self._send(500, {"ok": False, "error": str(e)}) + if p == "/api/speedtest": + if SPEEDTEST["running"]: + return self._send(409, {"ok": False, "error": "Ya hay un test en marcha"}) + with LOCK: + active = [j for j in JOBS if j["status"] in ("comprobando", "enviando")] + if active: + return self._send(409, {"ok": False, "error": "Pausa el envio antes del test (si no, se mezclan las medidas)"}) + url = (b.get("url") or "").strip() + if url: + url = E.resolve_url(url) + SPEEDTEST.update(running=True, result=None) + threading.Thread(target=_speedtest_thread, args=(url,), daemon=True).start() + return self._send(200, {"ok": True}) + 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())}) + if p == "/api/add": + lines = [x.strip() for x in str(b.get("lines", "")).splitlines() if x.strip()] + if not lines: + return self._send(400, {"ok": False, "error": "Pega al menos un enlace"}) + dest = (b.get("dest") or SETTINGS["dest"]).rstrip("/") or "/data/homebrew" + if not dest.startswith("/data") and not dest.startswith("/mnt/"): + return self._send(400, {"ok": False, "error": "El destino debe estar en /data o /mnt"}) + job = new_job(lines, (b.get("password") or "").strip(), dest, where=b.get("where")) + with WAKE: + JOBS.append(job) + WAKE.notify_all() + save_jobs() + return self._send(200, {"ok": True, "id": job["id"]}) + if p.startswith("/api/job/"): + parts = p.split("/") + if len(parts) != 5: + return self._send(404, {"ok": False}) + job_id, action = parts[3], parts[4] + with WAKE: + job = find(job_id) + if not job: + return self._send(404, {"ok": False, "error": "no existe"}) + if action == "pause": + if job["status"] == "pendiente": + job["status"] = "pausado" + job["phase"] = "En pausa (pulsa Reanudar para seguir)" + elif job["status"] in ("comprobando", "enviando"): + job["_pause"] = True + CANCELS.setdefault(job_id, threading.Event()).set() + elif action == "cancel": + CANCELS.setdefault(job_id, threading.Event()).set() + if job["status"] in ("pendiente", "en cola para descomprimir"): + job["status"] = "cancelado" + job["phase"] = "Cancelado" + elif action == "retry": + if job["status"] in ("error", "cancelado", "pausado"): + if b.get("password"): + job["password"] = b["password"].strip() + CANCELS.setdefault(job_id, threading.Event()).clear() + job["error"] = "" + if job.get("info") and job.get("phase") != "Cancelado" and \ + job.get("sent", 0) >= max(0, job.get("total", 0)) and job.get("total", -1) > 0: + job["status"] = "en cola para descomprimir" + job["phase"] = "Reintentando la descompresion" + EXTRACT_Q.append(job_id) + else: + job["status"] = "pendiente" + job["phase"] = "En cola" + WAKE.notify_all() + elif action == "remove": + if job["status"] in ("hecho", "error", "cancelado", "pausado"): + JOBS.remove(job) + elif action in ("up", "down"): + i = JOBS.index(job) + k = i - 1 if action == "up" else i + 1 + if 0 <= k < len(JOBS): + JOBS[i], JOBS[k] = JOBS[k], JOBS[i] + save_jobs() + return self._send(200, {"ok": True}) + if p == "/api/clear": + with LOCK: + JOBS[:] = [j for j in JOBS if j["status"] not in ("hecho", "cancelado")] + save_jobs() + return self._send(200, {"ok": True}) + if p == "/api/quit": + self._send(200, {"ok": True}) + threading.Thread(target=lambda: (time.sleep(0.5), os._exit(0)), daemon=True).start() + return + self._send(404, {"ok": False}) + + +SKIP_DIRS = {"windows", "program files", "program files (x86)", "programdata", "appdata", + "$recycle.bin", "system volume information", "node_modules", ".git", + "msocache", "recovery", "$windows.~bt", "$windows.~ws", "perflogs"} + + +def _search_roots(): + home = os.path.expanduser("~") + roots = [] + for sub in ("Downloads", "Descargas", "Desktop", "Escritorio", "Documents", + "Documentos", "Videos", "Vídeos", ""): + d = os.path.join(home, sub) if sub else home + if os.path.isdir(d) and d not in roots: + roots.append(d) + if os.name == "nt": + import string + for letter in string.ascii_uppercase: + d = letter + ":\\" + if os.path.isdir(d): + roots.append(d) + else: + roots.append("/") + return roots + + +def locate_files(files, budget=12.0, max_depth=6): + """Busca en el PC archivos por nombre y tamano (los que se sueltan en el navegador).""" + want = {} + for f in files: + try: + want.setdefault(str(f["name"]).lower(), []).append(int(f["size"])) + except Exception: + pass + found = {} + t0 = time.time() + seen = set() + for root in _search_roots(): + stack = [(root, 0)] + while stack and len(found) < len(want): + if time.time() - t0 > budget: + return found + path, depth = stack.pop() + key = os.path.normcase(path) + if key in seen: + continue + seen.add(key) + try: + with os.scandir(path) as it: + entries = list(it) + except OSError: + continue + if True: + for e in entries: + try: + if e.is_dir(follow_symlinks=False): + n = e.name.lower() + if depth < max_depth and n not in SKIP_DIRS and not n.startswith("."): + stack.append((e.path, depth + 1)) + elif e.name.lower() in want and e.name not in found: + if e.stat().st_size in want[e.name.lower()]: + found[e.name] = e.path + except OSError: + pass + if len(found) >= len(want): + break + return found + + +def pick_port(): + for port in (8777, 8778, 8779, 0): + s = socket.socket() + try: + s.bind(("127.0.0.1", port)) + p = s.getsockname()[1] + s.close() + return p + except OSError: + s.close() + return 0 + + +SESSION_FILE = os.path.join(DATA_DIR, "session.json") + + +def dropped_paths(): + """Rutas de archivos arrastrados sobre el .bat o enviados con 'Enviar a'.""" + out = [] + for a in sys.argv[1:]: + if a.startswith("--"): + continue + a = a.strip().strip('"') + if os.path.isfile(a): + out.append(os.path.abspath(a)) + elif os.path.isdir(a): + print("Es una carpeta, la salto (arrastra archivos): %s" % a) + return out + + +def running_session(): + """Devuelve (url_base, token) si ya hay un PS5 Directo funcionando.""" + try: + with open(SESSION_FILE, encoding="utf-8") as f: + ses = json.load(f) + base = "http://127.0.0.1:%d" % int(ses["port"]) + req = urllib.request.Request(base + "/api/state", headers={"X-Token": ses["token"]}) + with urllib.request.urlopen(req, timeout=3) as r: + json.load(r) + return base, ses["token"] + except Exception: + return None + + +def forward_to_running(ses, paths): + base, token = ses + try: + req = urllib.request.Request(base + "/api/add", + data=json.dumps({"lines": "\n".join(paths)}).encode(), + headers={"X-Token": token, "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=5) as r: + return json.load(r).get("ok", False) + except Exception: + return False + + +def _find_app_browser(): + """Edge (viene con Windows) o Chrome, para abrir la app en su propia ventana.""" + if os.name != "nt": + return None + cands = [] + for env in ("PROGRAMFILES(X86)", "PROGRAMFILES", "LOCALAPPDATA"): + root = os.environ.get(env) + if root: + cands.append(os.path.join(root, "Microsoft", "Edge", "Application", "msedge.exe")) + cands.append(os.path.join(root, "Google", "Chrome", "Application", "chrome.exe")) + for c in cands: + if os.path.isfile(c): + return c + return None + + +def open_window(url): + """Abre la interfaz como ventana de aplicacion. Devuelve el proceso o None.""" + exe = _find_app_browser() + if exe and "--browser" not in sys.argv: + import subprocess + profile = os.path.join(DATA_DIR, "ventana") + try: + return subprocess.Popen([exe, "--app=" + url, "--user-data-dir=" + profile, + "--window-size=1150,900", "--no-first-run", + "--no-default-browser-check"]) + except Exception: + pass + webbrowser.open(url) + return None + + +def busy(): + with LOCK: + return any(j["status"] not in ("hecho", "error", "cancelado", "pausado") for j in JOBS) + + +UI_SEEN = {"t": 0.0} + + +def watch_ui(grace=180): + """Si la ventana lleva un rato cerrada y no hay nada en marcha, se cierra el programa. + Si hay trabajo, sigue en segundo plano; abre PS5 Directo otra vez para verlo.""" + while True: + time.sleep(10) + seen = UI_SEEN["t"] + if seen and time.time() - seen > grace and not busy(): + os._exit(0) + + +def main(): + if sys.stdout is None: # abierto sin consola (pythonw): registro a archivo + try: + os.makedirs(DATA_DIR, exist_ok=True) + sys.stdout = sys.stderr = open(os.path.join(DATA_DIR, "ps5directo.log"), "a", + encoding="utf-8", buffering=1) + except Exception: + pass + paths = dropped_paths() + if "--after-update" in sys.argv: + for _ in range(60): + if not running_session(): + break + time.sleep(0.5) + ses = running_session() + if ses: + if paths: + forward_to_running(ses, paths) + print("Anadido a la cola de PS5 Directo (ya estaba abierto)") + else: + open_window(ses[0] + "/#t=" + ses[1]) # ya funcionaba: solo enseno la ventana + sys.exit(3) + load_state() + if paths: + job = new_job(paths, "", SETTINGS["dest"]) + with WAKE: + JOBS.append(job) + save_jobs() + for target in (transfer_worker, extract_worker): + threading.Thread(target=target, daemon=True).start() + port = pick_port() + srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) + try: + os.makedirs(DATA_DIR, exist_ok=True) + with open(SESSION_FILE, "w", encoding="utf-8") as f: + json.dump({"port": srv.server_address[1], "token": TOKEN}, f) + except Exception: + pass + url = "http://127.0.0.1:%d/#t=%s" % (srv.server_address[1], TOKEN) + print("PS5 Directo %s" % VERSION) + print("Abre en el navegador: %s" % url) + if "--no-browser" not in sys.argv: + threading.Timer(0.5, lambda: open_window(url)).start() + threading.Thread(target=watch_ui, daemon=True).start() + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..61eabfa --- /dev/null +++ b/engine.py @@ -0,0 +1,1233 @@ +"""Motor de PS5 Directo. + +- Descarga enlaces HTTP/HTTPS en paralelo (por rangos) y los envia EN ORDEN al + FTP de la PS5, sin escribir nada en el disco del PC. +- Une archivos troceados (.001, .002 ...) en uno solo mientras los envia. +- Habla con BFpilot (puerto 5905) para extraer RAR/7z/ZIP dentro de la consola, + listar, mover y borrar. + +Solo usa la biblioteca estandar de Python 3.9+. +""" + +import ftplib +import json +import math +import os +import re +import socket +import ssl +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/130.0 Safari/537.36") +MiB = 1024 * 1024 + + +class Cancelled(Exception): + pass + + +class PipeError(Exception): + pass + + +# --------------------------------------------------------------------------- +# Nombres de archivo +# --------------------------------------------------------------------------- + +ARCHIVE_RE = re.compile( + r"\.(part\d+\.rar|rar|r\d{2,3}|7z|7z\.\d{3}|zip|z\d{2})$", re.I) +SPLIT_RAW_RE = re.compile(r"^(?P.+)\.(?P\d{3})$") +IMAGE_EXTS = (".exfat", ".ffpkg", ".ffpfs", ".ffpfsc", ".ufs") + + +def display_name(name): + """Nombre sin la parte de volumen (conserva mayusculas).""" + for pat in (r"\.part\d+\.rar$", r"\.7z\.\d{3}$", r"\.r\d{2,3}$", r"\.z\d{2}$", + r"\.rar$", r"\.7z$", r"\.zip$", r"\.\d{3}$"): + new = re.sub(pat, "", name, flags=re.I) + if new != name: + return new + return name + + +def is_archive(name): + return bool(ARCHIVE_RE.search(name)) + + +def set_key(name): + """Nombre comun de un conjunto de volumenes (para agruparlos).""" + n = name + for pat in (r"\.part\d+\.rar$", r"\.7z\.\d{3}$", r"\.r\d{2,3}$", + r"\.z\d{2}$", r"\.rar$", r"\.7z$", r"\.zip$"): + new = re.sub(pat, "", n, flags=re.I) + if new != n: + return new.lower() + m = SPLIT_RAW_RE.match(n) + if m: + return m.group("base").lower() + return n.lower() + + +def volume_order(name): + """Orden natural de volumenes: part1 < part2 ..., .rar < .r00 < .r01, .001 < .002.""" + m = re.search(r"\.part(\d+)\.rar$", name, re.I) + if m: + return int(m.group(1)) + if re.search(r"\.rar$", name, re.I): + return 0 + m = re.search(r"\.r(\d{2,3})$", name, re.I) + if m: + return int(m.group(1)) + 1 + if re.search(r"\.zip$", name, re.I): + return 10 ** 6 + m = re.search(r"\.z(\d{2})$", name, re.I) + if m: + return int(m.group(1)) + m = re.search(r"\.(\d{3})$", name) + if m: + return int(m.group(1)) + return 0 + + +def first_volume(names): + """El volumen que hay que pasar al extractor.""" + names = list(names) + for n in names: + if re.search(r"\.part0*1\.rar$", n, re.I): + return n + for n in names: + if re.search(r"\.rar$", n, re.I) and not re.search(r"\.part\d+\.rar$", n, re.I): + return n + for n in names: + if re.search(r"\.7z\.0*1$", n, re.I) or re.search(r"\.7z$", n, re.I): + return n + for n in names: + if re.search(r"\.zip$", n, re.I): + return n + return sorted(names, key=volume_order)[0] + + +def password_guesses(names): + """Contrasenas tipicas sacadas del nombre: [WEB.COM] -> WEB.COM, web.com, www.web.com ...""" + out = [] + for n in names: + for tag in re.findall(r"[\[\(\{]([^\]\)\}]{3,60})[\]\)\}]", n): + tag = tag.strip() + for c in (tag, tag.lower(), tag.upper(), "www." + tag.lower()): + if c and c not in out: + out.append(c) + return out + + +def title_id(text): + # sin \b: en "...-PPSA19733_(02.004.000)" el "_" pegado impedia encontrarlo + m = re.search(r"(?= 0 and offset >= self.size: + return + if self.ranges and self.size > 0: + yield from _ranged(self.url, offset, self.size, cfg, cancel, meter) + else: + if offset > 0: + raise PipeError("El servidor no permite continuar a medias") + yield from _single(self.url, cancel, meter) + + +def _single(url, cancel, meter): + with _open(url, timeout=60) as r: + while True: + if cancel.is_set(): + raise Cancelled() + data = r.read(MiB) + if not data: + return + meter(len(data)) + yield data + + +def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None): + want = b - a + 1 + last = None + for attempt in range(tries): + if cancel.is_set(): + raise Cancelled() + got = bytearray() + try: + with _open(url, {"Range": "bytes=%d-%d" % (a, b)}, timeout=25) as r: + if r.status != 206: + raise PipeError("el servidor no respeto el rango pedido") + while len(got) < want: + if cancel.is_set(): + raise Cancelled() + piece = r.read(min(MiB, want - len(got))) + if not piece: + break + got += piece + meter(len(piece)) + if len(got) == want: + return bytes(got) + last = "respuesta corta (%d de %d bytes)" % (len(got), want) + except (Cancelled, PipeError): + raise + except urllib.error.HTTPError as e: + last = "HTTP %s" % e.code + if e.code in (429, 503, 509) and on_trouble: + on_trouble(last) + if e.code == 429: + time.sleep(10) + except Exception as e: + last = str(e) + if on_trouble: + on_trouble("corte") + time.sleep(min(30, 2 ** attempt)) + raise PipeError("Fallo al descargar un trozo: %s" % last) + + +# Alojamientos que limitan o castigan muchas conexiones a la vez (cuentas gratis, +# enlaces de un solo uso, debrid). Con ellos se usan pocas conexiones y sin rescates. +LIMITED_HOSTS = ("akirabox.", "datanodes.to", "vikingfile.com", "fileditch", "1fichier.com", + "rapidgator.net", "ddownload.com", "katfile.com", "nitroflare.com", + "torbox", "tb-cdn", "real-debrid", "rdb.gg", "alldebrid", "debrid-link", + "premiumize", "pikpak") +LIMITED_CAP = 2 + + +def host_conn_cap(url): + host = urllib.parse.urlparse(url).netloc.lower() + return LIMITED_CAP if any(h in host for h in LIMITED_HOSTS) else None + + +class _Limiter: + """Limite de velocidad comun a todos los envios (bytes/s; 0 = sin limite).""" + + def __init__(self): + self.lock = threading.Lock() + self.t = time.time() + self.allow = 0.0 + + def take(self, n, rate): + if not rate or rate <= 0: + return + while True: + with self.lock: + now = time.time() + self.allow = min(float(rate), self.allow + (now - self.t) * rate) + self.t = now + if self.allow >= min(n, rate): + self.allow -= n + return + wait = (min(n, rate) - self.allow) / rate + time.sleep(min(wait, 0.5)) + + +LIMITER = _Limiter() + + +def _ranged(url, start, end, cfg, cancel, meter): + chunk = max(1, int(cfg.get("chunk_mb", 8))) * MiB + workers = max(1, int(cfg.get("streams", 8))) + log = cfg.get("_log") or (lambda m: None) + cap = host_conn_cap(url) + if cap and workers > cap: + workers = cap + log("Este alojamiento limita las conexiones: uso %d por archivo" % cap) + can_hedge = cap is None + window = max(workers + 1, int(cfg.get("buffer_mb", 256)) * MiB // chunk) + total = int(math.ceil((end - start) / float(chunk))) + results = {} + cond = threading.Condition() + state = {"next_task": 0, "needed": 0, "error": None, "stop": False, + "allowed": workers, "active": 0, "trouble_t": 0.0} + + def trouble(kind): + """El servidor da problemas (429, 503, cortes): usar una conexion menos.""" + with cond: + now = time.time() + if state["allowed"] > 1 and now - state["trouble_t"] > 10: + state["allowed"] -= 1 + state["trouble_t"] = now + log("El servidor va con problemas (%s): bajo a %d conexiones" % (kind, state["allowed"])) + + def worker(): + while True: + with cond: + # recuperar conexiones si lleva un rato sin problemas + if (state["allowed"] < workers and state["trouble_t"] + and time.time() - state["trouble_t"] > 120): + state["allowed"] += 1 + state["trouble_t"] = time.time() + while (state["active"] >= state["allowed"] and not state["stop"] + and not cancel.is_set()): + cond.wait(0.5) + i = state["next_task"] + if i >= total or state["stop"]: + return + state["next_task"] += 1 + while (i >= state["needed"] + window and not state["stop"] + and not cancel.is_set()): + cond.wait(0.5) + if state["stop"] or cancel.is_set(): + return + started[i] = time.time() + state["active"] += 1 + try: + fetch(i) + finally: + with cond: + state["active"] -= 1 + cond.notify_all() + + def fetch(i, hedge=False): + a = start + i * chunk + b = min(end, a + chunk) - 1 + t0 = time.time() + try: + data = _fetch_range(url, a, b, cancel, meter, on_trouble=trouble) + except Exception as e: + with cond: + # si es la copia de rescate, o el original ya llego, no es grave + if not hedge and i >= state["needed"] and i not in results: + state["error"] = e + cond.notify_all() + return + with cond: + durations.append(time.time() - t0) + del durations[:-32] + if i >= state["needed"] and i not in results: + results[i] = data + cond.notify_all() + + started = {} + durations = [] + hedged = set() + threads = [threading.Thread(target=worker, daemon=True) for _ in range(min(workers, total))] + for t in threads: + t.start() + try: + for i in range(total): + with cond: + while i not in results: + if state["error"] is not None: + raise state["error"] + if cancel.is_set(): + raise Cancelled() + # Un trozo atascado frena todo el envio a la PS5: si tarda mucho + # mas de lo normal, se pide otra vez por otra conexion y gana + # la que llegue antes (como hacen los gestores de descargas). + if can_hedge and i in started and i not in hedged and durations: + typical = sorted(durations)[len(durations) // 2] + if time.time() - started[i] > max(4.0, 3 * typical): + hedged.add(i) + threading.Thread(target=fetch, args=(i, True), daemon=True).start() + cond.wait(0.5) + data = results.pop(i) + state["needed"] = i + 1 + cond.notify_all() + yield data + finally: + with cond: + state["stop"] = True + cond.notify_all() + + +class FileSource: + kind = "file" + + def __init__(self, path): + self.path = path.strip().strip('"') + self.original = self.path + self.name = safe_name(os.path.basename(self.path)) + self.size = -1 + self.ranges = True + + def probe(self): + if not os.path.isfile(self.path): + raise PipeError("No existe el archivo: %s" % self.path) + self.size = os.path.getsize(self.path) + return self + + def stream(self, offset, cfg, cancel, meter): + with open(self.path, "rb") as f: + f.seek(offset) + while True: + if cancel.is_set(): + raise Cancelled() + data = f.read(4 * MiB) + if not data: + return + meter(len(data)) + yield data + + +def make_source(text): + text = text.strip().strip('"') + if re.match(r"^https?://", text, re.I): + return UrlSource(text) + return FileSource(text) + + +def concat_stream(parts, offset, cfg, cancel, meter): + """Lee varias fuentes como si fueran un unico archivo (para unir .001 .002 ...).""" + base = 0 + for p in parts: + if offset < base + p.size: + yield from p.stream(max(0, offset - base), cfg, cancel, meter) + offset = base + p.size + base += p.size + + +# --------------------------------------------------------------------------- +# FTP de la PS5 +# --------------------------------------------------------------------------- + +class Ftp: + def __init__(self, cfg): + self.host = cfg["ps5_ip"] + self.port = int(cfg.get("ftp_port", 2121)) + self.user = cfg.get("ftp_user") or "anonymous" + self.pw = cfg.get("ftp_pass") or "" + + def connect(self): + if not self.host: + raise PipeError("Falta la IP de la PS5: ponla en Ajustes") + f = ftplib.FTP() + f.encoding = "utf-8" + f.connect(self.host, self.port, timeout=30) + f.login(self.user, self.pw) + f.set_pasv(True) + f.voidcmd("TYPE I") + try: + f.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + except Exception: + pass + return f + + @staticmethod + def size(f, path): + try: + s = f.size(path) + return int(s) if s is not None else None + except ftplib.all_errors: + return None + + @staticmethod + def mkdirs(f, path): + cur = "" + for part in [p for p in path.split("/") if p]: + cur += "/" + part + try: + f.mkd(cur) + except ftplib.error_perm: + pass + + def call(self, fn): + f = self.connect() + try: + return fn(f) + finally: + try: + f.quit() + except Exception: + try: + f.close() + except Exception: + pass + + +def upload(cfg, remote_final, total, make_iter, progress, cancel, log, resume=True): + """Envia un flujo a remote_final. Escribe en .ps5part y renombra al final. + + make_iter(offset) devuelve un iterador de bytes desde 'offset'. + progress(done_bytes) recibe el total enviado. + Si se corta la conexion, reconecta y continua desde lo que ya hay en la PS5. + """ + ftp = Ftp(cfg) + part = remote_final + ".ps5part" + retries = int(cfg.get("retries", 8)) + attempt = 0 + while True: + if cancel.is_set(): + raise Cancelled() + f = None + try: + f = ftp.connect() + Ftp.mkdirs(f, remote_final.rsplit("/", 1)[0]) + if total >= 0 and Ftp.size(f, remote_final) == total: + log("Ya estaba en la consola: %s" % remote_final) + progress(total) + return + offset = 0 + if resume: + have = Ftp.size(f, part) + if have and (total < 0 or have <= total): + offset = have + if total >= 0 and offset == total: + pass + else: + if offset: + log("Continuando desde %.1f MB" % (offset / MiB)) + progress(offset) + conn = None + if offset: + try: + conn = f.transfercmd("STOR " + part, rest=offset) + except ftplib.error_perm: + conn = f.transfercmd("APPE " + part) + else: + conn = f.transfercmd("STOR " + part) + sent = offset + try: + conn.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * MiB) + except Exception: + pass + rate_fn = cfg.get("_limit_bps") or (lambda: 0) + with conn: + for data in make_iter(offset): + mv = memoryview(data) + for k in range(0, len(mv), MiB): + piece = mv[k:k + MiB] + LIMITER.take(len(piece), rate_fn()) + if cancel.is_set(): + raise Cancelled() + conn.sendall(piece) + sent += len(piece) + progress(sent) + f.voidresp() + attempt = 0 + got = Ftp.size(f, part) + if total >= 0 and got is not None and got != total: + raise PipeError("Tamano en la consola %s distinto del esperado %s" % (got, total)) + try: + f.delete(remote_final) + except ftplib.all_errors: + pass + f.rename(part, remote_final) + log("Enviado: %s" % remote_final) + return + except Cancelled: + raise + except PipeError as e: + if "no permite continuar" in str(e): + log("El servidor no deja continuar; se empieza desde cero") + resume = False + try: + if f: + f.delete(part) + except Exception: + pass + attempt += 1 + if attempt > retries: + raise + log("Problema: %s. Reintento %d/%d" % (e, attempt, retries)) + time.sleep(min(60, 3 * attempt)) + except Exception as e: + attempt += 1 + if attempt > retries: + raise PipeError("Conexion con la PS5 perdida: %s" % e) + log("Conexion cortada (%s). Reintento %d/%d" % (e, attempt, retries)) + time.sleep(min(60, 3 * attempt)) + finally: + if f is not None: + try: + f.quit() + except Exception: + try: + f.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# BFpilot (puerto 5905) +# --------------------------------------------------------------------------- + +class BFpilot: + def __init__(self, cfg): + self.base = "http://%s:%s" % (cfg["ps5_ip"], int(cfg.get("bf_port", 5905))) + self.cfg = cfg + + def _req(self, method, path, params=None, form=None, timeout=20): + if not self.cfg.get("ps5_ip"): + raise PipeError("Falta la IP de la PS5: ponla en Ajustes") + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode(params) + data = None + headers = {"User-Agent": "PS5Directo"} + if form is not None: + data = urllib.parse.urlencode(form).encode() + headers["Content-Type"] = "application/x-www-form-urlencoded" + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + body = r.read() + except urllib.error.HTTPError as e: + body = e.read() + try: + j = json.loads(body.decode("utf-8", "replace")) + raise PipeError(j.get("error") or "HTTP %s" % e.code) + except ValueError: + raise PipeError("BFpilot respondio HTTP %s" % e.code) + try: + j = json.loads(body.decode("utf-8", "replace")) + except ValueError: + raise PipeError("Respuesta rara de BFpilot") + if isinstance(j, dict) and j.get("ok") is False: + raise PipeError(j.get("error") or "error de BFpilot") + return j + + def alive(self): + try: + j = self._req("GET", "/api/status", timeout=5) + return j.get("version") or j.get("tag") or "ok" + except Exception: + return None + + def listdir(self, path): + j = self._req("GET", "/api/fs/list", {"path": path}) + return j.get("entries", []) + + def exists(self, path): + try: + self._req("GET", "/api/fs/stat", {"path": path}) + return True + except PipeError: + return False + + def mkdir(self, path): + self._req("GET", "/api/fs/mkdir", {"path": path}) + + def rename(self, src, dst): + self._req("GET", "/api/fs/rename", {"src": src, "dst": dst}) + + def chmod(self, path, mode="777"): + self._req("GET", "/api/fs/chmod", {"path": path, "mode": mode}) + + def fix_perms(self, root, cancel=None, log=None): + """Pone permisos 777 a una carpeta y todo lo que hay dentro. + + El unrar de BFpilot copia los permisos que trae el RAR (666 o 444 si se hizo en + Windows): sin permiso de ejecucion la PS5 no la arranca (CE-107750-0). + Devuelve (cambiados, revisados, {nombre: modo_antiguo} de los importantes). + """ + changed = seen = 0 + notable = {} + ftp = None + use_ftp = False + + def do(path, mode_now): + nonlocal changed, use_ftp, ftp + if mode_now == 0o777: + return + if not use_ftp: + try: + self.chmod(path) + changed += 1 + return + except PipeError as e: + if "endpoint" not in str(e).lower() and "404" not in str(e): + raise + use_ftp = True + if log: + log("Este BFpilot no sabe cambiar permisos: lo hago por FTP") + if ftp is None: + ftp = Ftp(self.cfg).connect() + ftp.sendcmd("SITE CHMOD 777 " + path) + changed += 1 + + try: + do(root, None) + stack = [root] + while stack: + if cancel is not None and cancel.is_set(): + raise Cancelled() + d = stack.pop() + for e in self.listdir(d): + seen += 1 + path = d + "/" + e["name"] + mode = e.get("mode") + if e["name"].lower() == "eboot.bin" and mode is not None: + notable[path[len(root):].lstrip("/")] = mode + do(path, mode) + if e.get("dir"): + stack.append(path) + finally: + if ftp is not None: + try: + ftp.quit() + except Exception: + pass + return changed, seen, notable + + def read_text(self, path): + try: + j = self._req("GET", "/api/fs/text", {"path": path}) + return j.get("text") or j.get("content") or "" + except Exception: + return "" + + def wait_job(self, cancel, timeout=3600): + t0 = time.time() + while time.time() - t0 < timeout: + if cancel is not None and cancel.is_set(): + raise Cancelled() + j = self._req("GET", "/api/fs/job/status") + if not j.get("busy"): + if j.get("error"): + raise PipeError(j.get("error")) + return + time.sleep(1) + raise PipeError("BFpilot tarda demasiado") + + def delete(self, path, cancel=None): + for _ in range(60): + try: + self._req("POST", "/api/fs/delete", form={"path": path, "recursive": "1"}) + break + except PipeError as e: + if "already running" in str(e) or "another" in str(e): + time.sleep(2) + continue + if "No such file" in str(e) or "not found" in str(e).lower(): + return + raise + self.wait_job(cancel) + + def du(self, path): + """Tamano total de una carpeta (o archivo), o None.""" + try: + return int(self._req("GET", "/api/fs/du", {"path": path}).get("size") or 0) + except Exception: + return None + + def places(self): + try: + return self._req("GET", "/api/fs/places") + except Exception: + return {} + + def extract(self, src, dst, password, cancel, on_status, threads=0): + form = {"src": src, "dst": dst, "password": password or "", + "threads": str(max(0, min(8, int(threads or 0)))), "cleanupPartial": "1"} + for _ in range(120): + try: + self._req("POST", "/api/fs/archive/prepare", form=form) + break + except PipeError as e: + if "already running" in str(e) or "another" in str(e): + on_status({"state": "esperando", "current": "BFpilot ocupado con otra tarea"}) + time.sleep(5) + continue + raise + misses = 0 + while True: + if cancel.is_set(): + try: + self._req("GET", "/api/fs/job/cancel") + except Exception: + pass + raise Cancelled() + try: + j = self._req("GET", "/api/fs/archive/status") + misses = 0 + except Exception as e: + misses += 1 + if misses > 120: + raise PipeError("BFpilot ha dejado de responder: %s" % e) + time.sleep(2) + continue + mine = (j.get("source") == src and j.get("destination") == dst) + state = j.get("state") + if mine: + on_status(j) + if state == "done": + return j + if state == "error": + raise PipeError(j.get("error") or "la extraccion fallo") + time.sleep(1) + + +def is_password_error(msg): + m = (msg or "").lower() + return "password" in m or "checksum" in m or "crc" in m + + +# --------------------------------------------------------------------------- +# Descomprimir en el PC con 7-Zip, sobre la marcha, y mandar los archivos sueltos +# --------------------------------------------------------------------------- +# +# 7-Zip saca TODOS los archivos seguidos por su salida estandar (x -so), en el +# mismo orden que su listado. Con el listado (nombres, tamanos y CRC) cortamos ese +# chorro en archivos y los subimos uno a uno por FTP. No se escribe nada +# descomprimido en el disco del PC, y cada archivo se comprueba con su CRC. + +import subprocess +import zlib +from collections import deque + + +def find_7z(custom=""): + cands = [] + if custom: + c = custom.strip().strip('"') + cands.append(os.path.join(c, "7z.exe") if os.path.isdir(c) else c) + for env in ("PROGRAMFILES", "PROGRAMW6432", "PROGRAMFILES(X86)", "LOCALAPPDATA"): + root = os.environ.get(env) + if root: + cands.append(os.path.join(root, "7-Zip", "7z.exe")) + cands.append(os.path.join(root, "Programs", "7-Zip", "7z.exe")) + import shutil + for n in ("7z", "7zz", "7z.exe"): + w = shutil.which(n) + if w: + cands.append(w) + for c in cands: + if c and os.path.isfile(c): + return c + return None + + +_NOWIN = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW + + +def _7z_cmd(exe, *args, password=None): + cmd = [exe] + list(args) + ["-sccUTF-8", "-bd", "-y"] + if password: + cmd.append("-p" + password) + return cmd + + +def _pw_error(text): + t = (text or "").lower() + return ("wrong password" in t or "encrypted" in t or "cannot read password" in t + or "can not read password" in t or "break signaled" in t) + + +def list_7z(exe, archive, password=None): + """Devuelve (entradas, propiedades del archivo). Cada entrada: path, size, dir, crc, enc.""" + p = subprocess.run(_7z_cmd(exe, "l", "-slt", archive, password=password), + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=_NOWIN, timeout=600) + out = p.stdout.decode("utf-8", "replace") + err = p.stderr.decode("utf-8", "replace") + if p.returncode != 0 or "----------" not in out: + msg = (err.strip() or out.strip()[-400:] or "codigo %s" % p.returncode) + raise PipeError("7-Zip no pudo leer el archivo: %s" % msg[-400:]) + head, body = out.split("----------", 1) + props = {} + for line in head.splitlines(): + if " = " in line: + k, v = line.split(" = ", 1) + props[k.strip()] = v.strip() + entries = [] + for block in re.split(r"\r?\n\s*\r?\n", body): + d = {} + for line in block.splitlines(): + if " = " in line: + k, v = line.split(" = ", 1) + d.setdefault(k.strip(), v.strip()) + elif line.endswith(" ="): + d.setdefault(line[:-2].strip(), "") + if "Path" not in d: + continue + attrs = d.get("Attributes", "") + is_dir = d.get("Folder") == "+" or attrs.startswith("D") + path = d["Path"].replace("\\", "/").strip("/") + parts = [x for x in path.split("/") if x not in ("", ".")] + if not parts or ".." in parts: + raise PipeError("Ruta peligrosa dentro del comprimido: %s" % d["Path"]) + if d.get("Hard Link") or d.get("Symbolic Link"): + raise PipeError("El comprimido tiene enlaces (%s); eso solo lo sabe extraer BFpilot. " + "Descomprimelo en la PS5." % path) + if d.get("Alternate Stream") == "+": + continue + crc = d.get("CRC") or "" + entries.append({ + "path": "/".join(parts), "dir": is_dir, + "size": 0 if is_dir else int(d.get("Size") or 0), + "crc": int(crc, 16) if re.fullmatch(r"[0-9A-Fa-f]{8}", crc) else None, + "enc": d.get("Encrypted") == "+", + }) + return entries, props + + +def needs_password(entries): + return any(e["enc"] for e in entries) + + +class _FtpWriter: + """Una conexion FTP abierta para subir muchos archivos seguidos, con reconexion. + + Guarda los ultimos MB enviados del archivo en curso: si se corta la conexion, + pregunta a la PS5 cuanto le llego y reenvia desde ahi sin volver a descomprimir. + """ + KEEP = 64 * MiB + + def __init__(self, cfg, cancel, log): + self.cfg, self.cancel, self.log = cfg, cancel, log + self.ftp = Ftp(cfg) + self.f = None + self.conn = None + self.dirs = set() + self.retries = int(cfg.get("retries", 8)) + self.rate_fn = cfg.get("_limit_bps") or (lambda: 0) + + def _connect(self): + self.close() + self.f = self.ftp.connect() + self.dirs = set() + + def mkdir(self, path): + if path in self.dirs: + return + self._retry(lambda: Ftp.mkdirs(self.f, path)) + cur = "" + for part in [p for p in path.split("/") if p]: + cur += "/" + part + self.dirs.add(cur) + + def _retry(self, fn): + attempt = 0 + while True: + if self.cancel.is_set(): + raise Cancelled() + try: + if self.f is None: + self._connect() + return fn() + except (Cancelled, PipeError): + raise + except Exception as e: + attempt += 1 + self.close() + if attempt > self.retries: + raise PipeError("Conexion con la PS5 perdida: %s" % e) + self.log("Conexion cortada (%s). Reintento %d/%d" % (e, attempt, self.retries)) + time.sleep(min(30, 3 * attempt)) + + def _open_data(self, path, offset): + if offset: + c = self.f.transfercmd("STOR " + path, rest=offset) + else: + c = self.f.transfercmd("STOR " + path) + try: + c.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2 * MiB) + except Exception: + pass + return c + + def begin(self, path): + self.path = path + self.sent = 0 + self.tail = deque() + self.tail_len = 0 + self.mkdir(path.rsplit("/", 1)[0] or "/") + self.conn = self._retry(lambda: self._open_data(path, 0)) + + def _remember(self, data): + self.tail.append(data) + self.tail_len += len(data) + while self.tail and self.tail_len - len(self.tail[0]) >= self.KEEP: + self.tail_len -= len(self.tail.popleft()) + + def _recover(self, why): + """Reconecta y reenvia lo que no llego, desde la copia de los ultimos MB.""" + attempt = 0 + while True: + attempt += 1 + if attempt > self.retries: + raise PipeError("Conexion con la PS5 perdida: %s" % why) + self.log("Conexion cortada (%s). Reintento %d/%d" % (why, attempt, self.retries)) + time.sleep(min(30, 3 * attempt)) + if self.cancel.is_set(): + raise Cancelled() + try: + self._connect() + have = Ftp.size(self.f, self.path) or 0 + start = self.sent - self.tail_len + if have < start or have > self.sent: + raise PipeError("se perdio demasiado del archivo %s; empieza otra vez" % self.path) + self.conn = self._open_data(self.path, have) + skip = have - start + for chunk in self.tail: + if skip >= len(chunk): + skip -= len(chunk) + continue + self.conn.sendall(memoryview(chunk)[skip:]) + skip = 0 + return + except (Cancelled, PipeError): + raise + except Exception as e: + why = str(e) + + def write(self, data): + mv = memoryview(data) + for k in range(0, len(mv), MiB): + piece = mv[k:k + MiB] + LIMITER.take(len(piece), self.rate_fn()) + if self.cancel.is_set(): + raise Cancelled() + chunk = bytes(piece) + self.sent += len(chunk) + self._remember(chunk) + try: + self.conn.sendall(chunk) + except Exception as e: + self._recover(str(e)) + + def end(self, size): + for attempt in range(3): + try: + self.conn.close() + self.conn = None + self.f.voidresp() + got = Ftp.size(self.f, self.path) + if got is not None and got != size: + raise PipeError("tamano en la consola %s distinto del esperado %s" % (got, size)) + return + except PipeError: + raise + except Exception as e: + self._recover(str(e)) + raise PipeError("No pude cerrar %s en la PS5" % self.path) + + def close(self): + for o in (self.conn, self.f): + if o is not None: + try: + o.close() + except Exception: + pass + self.conn = self.f = None + + +def stream_extract_7z(exe, archive, entries, password, remote_root, cfg, cancel, log, + progress, on_file=None, skip=0, capture=None): + """Descomprime con 7-Zip hacia la salida estandar y sube cada archivo a remote_root. + + skip: cuantas entradas iniciales ya estaban subidas (se leen y se descartan). + progress(bytes_subidos_en_total, ruta_actual). on_file(indice_hecho). + Devuelve el numero de entradas hechas. + """ + proc = subprocess.Popen(_7z_cmd(exe, "x", "-so", "-bb0", "-bsp0", archive, password=password), + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=_NOWIN, bufsize=0) + errbuf = [] + threading.Thread(target=lambda: errbuf.append(proc.stderr.read()), daemon=True).start() + stop_watch = threading.Event() + + def watch_cancel(): + while not stop_watch.wait(0.5): + if cancel.is_set(): + try: + proc.kill() + except Exception: + pass + return + threading.Thread(target=watch_cancel, daemon=True).start() + + def err_text(): + try: + proc.wait(timeout=10) + except Exception: + pass + time.sleep(0.2) + return b"".join(x for x in errbuf if x).decode("utf-8", "replace").strip() + + w = _FtpWriter(cfg, cancel, log) + done_bytes = sum(e["size"] for e in entries[:skip]) + i = 0 + try: + for i, e in enumerate(entries): + remote = remote_root + "/" + e["path"] + if e["dir"]: + if i >= skip: + w.mkdir(remote) + if on_file: + on_file(i + 1) + continue + left = e["size"] + crc = 0 + upload = i >= skip + keep = [] if capture is not None and e["path"] in capture and e["size"] < MiB else None + if upload: + w.begin(remote) + while left > 0: + data = proc.stdout.read(min(4 * MiB, left)) + if cancel.is_set(): + raise Cancelled() + if not data: + msg = err_text() + if _pw_error(msg): + raise PipeError("7-Zip: contrasena incorrecta (%s)" % msg[-200:]) + raise PipeError("7-Zip se paro en %s: %s" % (e["path"], msg[-300:] or "sin datos")) + left -= len(data) + crc = zlib.crc32(data, crc) + if keep is not None: + keep.append(data) + if upload: + w.write(data) + done_bytes += len(data) + progress(done_bytes, e["path"]) + if e["crc"] is not None and crc != e["crc"]: + raise PipeError("El archivo %s no cuadra con su CRC (datos danados o contrasena " + "incorrecta)" % e["path"]) + if keep is not None: + capture[e["path"]] = b"".join(keep) + if upload: + w.end(e["size"]) + if on_file: + on_file(i + 1) + extra = proc.stdout.read(1) + rc = proc.wait(timeout=120) + if cancel.is_set(): + raise Cancelled() + if extra or rc != 0: + msg = err_text() + raise PipeError("7-Zip termino con errores (codigo %s): %s" % (rc, msg[-300:])) + return len(entries) + finally: + stop_watch.set() + w.close() + if proc.poll() is None: + try: + proc.kill() + except Exception: + pass diff --git a/extension/bg.js b/extension/bg.js new file mode 100644 index 0000000..d8098fa --- /dev/null +++ b/extension/bg.js @@ -0,0 +1,70 @@ +// 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; + } + 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 }) + }); + 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: "Enviado a PS5 Directo", + message: (name || "Enlace") + " — 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/extension/icon128.png b/extension/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..85c4fb876599f7850c57e642ff8db4ef6f4d9047 GIT binary patch literal 11923 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 IM6N<$g2>-t-2eap literal 0 HcmV?d00001 diff --git a/extension/icon32.png b/extension/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..ef53dac6f73c3c55ac6243a0f5500c418848bc1b GIT binary patch literal 1816 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 + + + +

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/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..a493dc3 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,26 @@ +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 ? `Enviados: ${cfg.captured}${cfg.last ? " (último: " + 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 = `● Conectado a PS5 Directo v${app.version}`; chrome.action.setBadgeText({ text: "" }); } + else st.innerHTML = '● PS5 Directo no está abierto (las descargas seguirán normales)'; +})(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5c3dd90 --- /dev/null +++ b/web/index.html @@ -0,0 +1,626 @@ + + + + + +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.
+
+
+ + +
+
+ + + +
+
+ + +
+
+
+ + 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

+
+ +
+
+
+
+ + +

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.

+
+
+
+ + +

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).

+
+
+
+
+
+
+
+ + + + +
+
+ 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/web/logo.svg b/web/logo.svg new file mode 100644 index 0000000..7bc157e --- /dev/null +++ b/web/logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/ps5directo.ico b/web/ps5directo.ico new file mode 100644 index 0000000000000000000000000000000000000000..04a48ee2ba919e119d7de15c57b395706f326b21 GIT binary patch literal 63686 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 KYnH29hx|W6`N*9B literal 0 HcmV?d00001