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