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 0000000..85c4fb8 Binary files /dev/null and b/extension/icon128.png differ diff --git a/extension/icon16.png b/extension/icon16.png new file mode 100644 index 0000000..b6a8ca2 Binary files /dev/null and b/extension/icon16.png differ diff --git a/extension/icon32.png b/extension/icon32.png new file mode 100644 index 0000000..ef53dac Binary files /dev/null and b/extension/icon32.png differ diff --git a/extension/icon48.png b/extension/icon48.png new file mode 100644 index 0000000..0a3ac25 Binary files /dev/null and b/extension/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..ed55e58 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 3, + "name": "PS5 Directo - Capturar enlaces", + "version": "1.0.0", + "description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace a PS5 Directo en vez de bajarlo al PC.", + "permissions": ["downloads", "storage", "notifications"], + "host_permissions": ["http://127.0.0.1/*"], + "background": { "service_worker": "bg.js" }, + "action": { "default_popup": "popup.html", "default_title": "PS5 Directo", "default_icon": { "16": "icon16.png", "32": "icon32.png" } }, + "icons": { "16": "icon16.png", "48": "icon48.png", "128": "icon128.png" } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..bc164ea --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,20 @@ + + + + +

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 0000000..04a48ee Binary files /dev/null and b/web/ps5directo.ico differ