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