Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80bba19d57 | ||
|
|
088c46f548 | ||
|
|
b65a3b34dd | ||
|
|
3e642e0986 | ||
|
|
d8113534ea | ||
|
|
5128f49f8b | ||
|
|
c8a0466854 | ||
|
|
3314a0f1b0 | ||
|
|
baa4616faf | ||
|
|
d9c33091ca | ||
|
|
4fc970e7d8 | ||
|
|
65911f790d |
No files matched your search
@@ -2,11 +2,15 @@
|
||||
chcp 65001 >nul
|
||||
title PS5 Directo
|
||||
cd /d "%~dp0"
|
||||
where py >nul 2>nul
|
||||
if %errorlevel%==0 (
|
||||
py -3 app.py %*
|
||||
if exist "%~dp0python\python.exe" (
|
||||
"%~dp0python\python.exe" app.py %*
|
||||
) else (
|
||||
python app.py %*
|
||||
where py >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
py -3 app.py %*
|
||||
) else (
|
||||
python app.py %*
|
||||
)
|
||||
)
|
||||
if %errorlevel%==3 exit /b 0
|
||||
echo.
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
PS5 DIRECTO 1.14
|
||||
================
|
||||
PS5 DIRECTO 1.20.0
|
||||
==================
|
||||
(English: see README_EN.txt)
|
||||
|
||||
EMPIEZA AQUÍ (5 MINUTOS)
|
||||
========================
|
||||
Lo que necesitas antes:
|
||||
- Una PS5 con el jailbreak ya hecho. PS5 Directo no hace el jailbreak.
|
||||
- Un PC con Windows 10 u 11 conectado a la misma red que la PS5.
|
||||
|
||||
Paso 1. Descarga
|
||||
En la página de GitHub del programa, entra en "Releases" y descarga
|
||||
PS5Directo-X.Y.Z-portable-windows.zip (trae todo, no hay que instalar
|
||||
nada más). Descomprímelo en una carpeta, por ejemplo en Documentos.
|
||||
|
||||
Paso 2. Ábrelo
|
||||
Doble clic en "PS5 Directo.vbs". Se abre en su propia ventana y crea un
|
||||
icono en el escritorio para la próxima vez. Si Windows pregunta si quieres
|
||||
abrir un archivo descargado de internet, pulsa "Abrir".
|
||||
|
||||
Paso 3. En la PS5
|
||||
Carga el jailbreak como siempre y ShadowMountPlus (es lo que hace que lo
|
||||
que envíes aparezca en el menú). Si tu menú de payloads también carga el
|
||||
FTP (ftpsrv) y BFpilot, mejor; si no, PS5 Directo te ayuda en el paso 5.
|
||||
|
||||
Paso 4. La IP de la PS5
|
||||
El asistente que sale al abrir te la pide. En la PS5: Ajustes > Red >
|
||||
Ver estado de la conexión > "Dirección IPv4" (algo como 192.168.1.50).
|
||||
|
||||
Paso 5. Comprobar la conexión
|
||||
El asistente mira si responden el FTP y BFpilot. Si alguno sale en rojo,
|
||||
pulsa "Cargar" a su lado y se envía solo. (Con Y2JB Autoloader eso no
|
||||
funciona: cárgalos desde Payload Manager en la PS5 y pulsa "Volver a
|
||||
comprobar".)
|
||||
|
||||
Paso 6. Envía algo
|
||||
Pega un enlace de descarga directa (o arrastra un archivo de tu PC a la
|
||||
ventana) y pulsa "Añadir a la cola". Cuando ponga "Listo", ya está en el
|
||||
menú de la PS5.
|
||||
|
||||
¿Algo no va?
|
||||
Ajustes > "Revisión completa" dice qué falla y cómo arreglarlo. Los dos
|
||||
puntos de arriba (FTP y BFpilot) en rojo: púlsalos para cargarlos. Puedes
|
||||
repetir el asistente en Ajustes > "Asistente de primeros pasos".
|
||||
|
||||
Aviso: PS5 Directo solo copia archivos. Úsalo con contenido que tengas
|
||||
derecho a usar (tus copias de seguridad y homebrew). Lo que hagas con él
|
||||
es responsabilidad tuya.
|
||||
|
||||
(Lo de abajo es la guía completa: no hace falta leerla para empezar.)
|
||||
|
||||
|
||||
Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola:
|
||||
|
||||
@@ -15,6 +64,38 @@ Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola:
|
||||
Mientras una transferencia se descomprime en la PS5, la siguiente de la cola ya se
|
||||
va enviando.
|
||||
|
||||
PESTAÑAS
|
||||
- Transferencias: la cola de siempre.
|
||||
- Archivos de la consola: navega por la PS5 como en un explorador. Ves el
|
||||
tamaño y la fecha de cada archivo (el de las carpetas, con "calcular"),
|
||||
y puedes crear carpetas, renombrar, copiar o mover (Copiar/Mover y
|
||||
luego "Pegar aquí" en la carpeta de destino), borrar, bajar al PC
|
||||
(a Descargas\PS5 Directo), poner permisos 777 y subir archivos o
|
||||
carpetas tal cual a la carpeta en la que estás.
|
||||
Clic derecho en un archivo o en el hueco para el menú; atajos: Supr,
|
||||
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A y Retroceso. "Mover a…" y "Copiar
|
||||
a…" abren un selector de carpetas con los destinos recientes. Si al pegar
|
||||
ya existe algo con el mismo nombre, eliges reemplazar/combinar, conservar
|
||||
ambos u omitir. "Extraer…" descomprime en la consola un .rar/.7z/.zip que
|
||||
ya esté allí. A la izquierda tienes las unidades (con su espacio libre) y
|
||||
accesos rápidos. Puedes arrastrar archivos del PC a la carpeta abierta.
|
||||
Sin BFpilot casi todo funciona por FTP (copiar pasa por el PC, más
|
||||
lento); descomprimir y ver el espacio libre sí necesitan BFpilot.
|
||||
- Espacio: gráfico de quesitos con lo que ocupa sitio en la consola, con
|
||||
el nombre de cada aplicación y su tamaño. Avisa de contenido repetido (por
|
||||
ejemplo la carpeta y su versión convertida a imagen), envíos a medias y
|
||||
restos, y permite borrarlos desde ahí (pide confirmación). Necesita
|
||||
BFpilot. Lo instalado desde la propia PS5 se ve pero se borra
|
||||
desde la consola.
|
||||
- Actividad: historial de lo que se ha terminado o ha fallado.
|
||||
|
||||
CARPETAS DEL PC
|
||||
Con "Elegir carpeta…" (o arrastrando la carpeta al icono) se manda una
|
||||
carpeta entera tal cual. Si tiene sce_sys, va a /data/homebrew como
|
||||
TITLEID-app. Si en la consola ya hay parte (por ejemplo, un envío de otro
|
||||
programa que se cortó), salta los archivos que ya están iguales y
|
||||
continúa los que se quedaron a medias.
|
||||
|
||||
ALTERNATIVA: DESCOMPRIMIR EN EL PC (7-Zip)
|
||||
En "Dónde descomprimir" elige "En el PC (7-Zip)" (o ponlo por defecto en
|
||||
Ajustes). Entonces:
|
||||
@@ -142,6 +223,215 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
|
||||
Ajustes.
|
||||
|
||||
|
||||
BIBLIOTECA
|
||||
----------
|
||||
La pestaña Biblioteca enseña lo que hay en /data/homebrew (y en los USB) con su
|
||||
portada, nombre, Title ID, versión, si es carpeta o imagen (.ffpkg, .ffpfsc,
|
||||
.exfat) y qué firmware pide. Si pones el firmware de tu PS5 en Ajustes
|
||||
(por ejemplo 13.60), marca en rojo los que no arrancarán. También avisa de
|
||||
contenido repetido, de DLC/actualizaciones y de carpetas incompletas. Funciona
|
||||
solo con FTP; con BFpilot además ve cuánto ocupa cada carpeta.
|
||||
El firmware sale de param.json y, si trae backport (carpeta
|
||||
fakelib), de lo que piden sus ejecutables. Al terminar cada transferencia
|
||||
también se comprueba y te avisa si no va a arrancar.
|
||||
|
||||
|
||||
IMÁGENES Y LZ4 (PS5 DUMP FORGE)
|
||||
-------------------------------
|
||||
En "Cómo dejarlo en la PS5" la opción por defecto es «Sin convertir»: se deja
|
||||
tal como viene (un comprimido se descomprime; si ya es una imagen .ffpkg,
|
||||
.exfat o .ffpfsc, o viene en LZ4, se envía sin tocar). Además puedes elegir:
|
||||
- Carpeta normal: siempre acaba en carpeta; si es una imagen, un .fpkg o
|
||||
viene en LZ4, se deshace en el PC.
|
||||
- Imagen .ffpkg: la que recomienda ShadowMountPlus. Velocidad completa.
|
||||
- Imagen .exfat o .ffpfs: también las monta ShadowMountPlus.
|
||||
- Imagen comprimida .ffpfsc: ocupa un 30-60 % menos, pero la consola la lee
|
||||
más despacio (150-250 MB/s) y algunos títulos pueden dar tirones.
|
||||
- Paquete .fpkg: se instala como una app de la tienda. Solo firmware 11.60
|
||||
o inferior (kstuff, fpkg-enable y ppr-patch). Se deja en /data/pkg.
|
||||
Para .fpkg y .ffpfsc puedes elegir la compresión (rápida, equilibrada,
|
||||
mínimo tamaño o sin comprimir).
|
||||
- LZ4 (carpeta o dentro de una .ffpkg): solo títulos AMPR. Experimental: con
|
||||
algunos títulos se cuelga al guardar partida. Si no es AMPR, se
|
||||
manda como carpeta o como .ffpkg normal.
|
||||
Se prepara en el PC con PS5 Dump Forge (github.com/quer3q/ps5-dump-forge, se
|
||||
descarga solo la primera vez) y se envía un único archivo: va más rápido por
|
||||
FTP que miles de archivos pequeños y no tiene el problema de permisos. Hace
|
||||
falta sitio en el PC para la imagen. Las imágenes .ffpkg/.ffpfsc no son
|
||||
paquetes FPKG: no se instalan, ShadowMountPlus las monta (hasta 13.60 con
|
||||
ShadowMountPlus 1.7). Los DLC y actualizaciones se mandan como carpeta.
|
||||
|
||||
|
||||
ANTES DE ENVIAR
|
||||
---------------
|
||||
- Si ya está en la consola (mismo Title ID), la transferencia se
|
||||
para y te pregunta: enviar igualmente, reemplazar la copia que hay o
|
||||
cancelar. Se puede desactivar en Ajustes.
|
||||
- Si pegas el .sfv / .md5 / .sha1 junto con las partes (o están en la misma
|
||||
carpeta del PC), cada parte se comprueba mientras se envía. Si una llega
|
||||
dañada, se borra de la consola y se avisa antes de descomprimir.
|
||||
- Si falta sitio, el error trae un botón "Hacer sitio": en la pestaña Espacio
|
||||
te propone qué borrar (restos, copias repetidas, instaladores .pkg) hasta
|
||||
llegar a lo que necesitas. Nada se borra sin confirmar.
|
||||
|
||||
|
||||
PAYLOADS, DIAGNÓSTICO, MÓVIL Y PROGRAMAR
|
||||
----------------------------------------
|
||||
- Pulsa en los puntos FTP / BFpilot (o Ajustes > Cargar payloads) para
|
||||
mandar ftpsrv o BFpilot a la PS5 por el cargador de ELF (puerto 9021,
|
||||
etaHEN o elfldr). Se descargan de GitHub y se guardan. El cargador de
|
||||
Y2JB Autoloader solo acepta la propia consola: con él usa Payload Manager.
|
||||
- "¿Por qué va lento?" revisa si el PC va por Wi-Fi o cable, la banda y la
|
||||
señal, cables a 100 Mb/s, VPN, redes distintas y el ping a la PS5, y te
|
||||
dice qué cambiar.
|
||||
- El botón del móvil (arriba) enseña un código QR para abrir PS5 Directo
|
||||
desde el móvil en la misma red: pegar enlaces, ver la cola, pausar, ver
|
||||
tu biblioteca. Desde el móvil no se pueden instalar actualizaciones ni
|
||||
cambiar rutas de programas.
|
||||
- "Programar" (en la cola): empezar a una hora y suspender o apagar el PC
|
||||
al terminar (avisa y espera 1 minuto). Mientras hay envíos, el PC no se
|
||||
suspende solo.
|
||||
|
||||
|
||||
VERSIÓN PORTABLE
|
||||
----------------
|
||||
En cada versión hay dos descargas: PS5Directo-X.Y.Z.zip (necesita Python
|
||||
instalado) y PS5Directo-X.Y.Z-portable-windows.zip, que trae Python dentro:
|
||||
descomprimir y abrir, sin instalar nada.
|
||||
|
||||
|
||||
NOVEDADES 1.20.0
|
||||
- Asistente de primeros pasos (IP, comprobar la conexión y cargar lo que
|
||||
falta, firmware) y una guía más corta y sencilla.
|
||||
- Pestaña Biblioteca: lo que hay en la consola con portada, versión, formato y
|
||||
el firmware que pide; marca los que no arrancan en tu firmware, repetidos,
|
||||
DLC e incompletos.
|
||||
- Imágenes .ffpkg / .ffpfsc y LZ4 con PS5 Dump Forge (opcional): se preparan
|
||||
en el PC y se envía un solo archivo. Se eligen con tarjetas debajo del
|
||||
destino, con la explicación de cada uno y el espacio que hace falta en el PC;
|
||||
la primera vez avisa de que convertir ocupa sitio en el PC hasta terminar.
|
||||
Si lo descargado ya es una imagen, se envía tal cual o se convierte al
|
||||
formato elegido, y se coloca aunque BFpilot no esté cargado.
|
||||
- Nuevos formatos: carpeta normal, .exfat, .ffpfs y .fpkg (con compresión
|
||||
a elegir). Se aceptan carpetas, .zip/.7z/.rar, imágenes, .fpkg y LZ4, y
|
||||
sin 7-Zip el contenido se saca con PS5 Dump Forge.
|
||||
- Los formatos van agrupados (primero los que ocupan menos) y el botón
|
||||
«¿Cuál elijo?» compara ventajas e inconvenientes de cada uno.
|
||||
- Tiempo que queda (y hora aproximada) al enviar, descomprimir y convertir.
|
||||
- En el móvil, al terminar o fallar algo sale un aviso grande, vibra y pita
|
||||
(con la página abierta).
|
||||
- Biblioteca: marca como LZ4 las carpetas empaquetadas en LZ4 (y filtro LZ4).
|
||||
- Arreglado: al reanudar un envío preguntaba «Ya está en la consola» por lo
|
||||
que ese mismo envío había dejado a medias.
|
||||
- Arreglado: en algunos Chrome de Android la página del móvil salía en
|
||||
blanco.
|
||||
- Abrir en el móvil: la página va comprimida (carga antes por Wi-Fi) y la
|
||||
ventana muestra «Qué le llega del móvil» para saber si llega y si se corta.
|
||||
- Aviso de firmware: pon el de tu PS5 en Ajustes y te avisa de lo que no
|
||||
arrancará (tiene en cuenta el backport).
|
||||
- Si ya está en la consola, pregunta antes de enviarlo (enviar
|
||||
igualmente, reemplazar o cancelar).
|
||||
- Comprobación con .sfv / .md5 / .sha1 mientras se envía.
|
||||
- "Hacer sitio": propone qué borrar cuando falta espacio.
|
||||
- Cargar ftpsrv y BFpilot en la PS5 desde el programa (puerto 9021).
|
||||
- "¿Por qué va lento?": diagnóstico de la red del PC.
|
||||
- Abrir desde el móvil con un código QR.
|
||||
- Programar la cola, suspender o apagar al terminar, y el PC no se
|
||||
suspende mientras envía.
|
||||
- Versión portable con Python incluido.
|
||||
- Arreglado: solo con FTP (sin BFpilot), ftpsrv devolvía la raíz de la consola
|
||||
al listar una carpeta; afectaba al explorador y al envío de carpetas.
|
||||
- Abrir en el móvil: botón para dar permiso en el Firewall de Windows si la
|
||||
página se queda en blanco.
|
||||
|
||||
NOVEDADES 1.19.2
|
||||
- Arreglado "504 Option not supported" en Archivos de la consola cuando
|
||||
BFpilot no está cargado: el explorador ya lista las carpetas solo con
|
||||
ftpsrv (y con servidores FTP que no tienen MLSD).
|
||||
|
||||
NOVEDADES 1.19.1
|
||||
- Arreglado: la cola dejaba de actualizarse sola (desde la 1.18.3), así que
|
||||
"Quitar" parecía no hacer nada y luego decía "no existe".
|
||||
- El explorador vuelve a listar la carpeta abierta cuando termina una subida
|
||||
a esa carpeta.
|
||||
- Arreglado "Descargar al PC" con aplicaciones: ftpsrv descifraba al vuelo los
|
||||
eboot.bin/.prx/.sprx y la descarga salía incompleta. Ahora se transfieren
|
||||
tal cual están (también mejora comparar tamaños al reanudar carpetas).
|
||||
Gracias a RichyMX por encontrarlo y proponer el arreglo.
|
||||
|
||||
NOVEDADES 1.19
|
||||
- Explorador de archivos renovado: menú de clic derecho, atajos de teclado,
|
||||
ruta por tramos, unidades y accesos a la izquierda, "Mover a…" / "Copiar
|
||||
a…" con destinos recientes, aviso al pegar si ya existe (reemplazar,
|
||||
combinar, conservar ambos u omitir), "Extraer…" de comprimidos que ya
|
||||
están en la consola, y arrastrar archivos del PC a la carpeta abierta.
|
||||
- El explorador ya no necesita BFpilot para casi nada: renombrar, mover,
|
||||
borrar, crear carpetas, permisos, tamaños y copiar funcionan solo con FTP.
|
||||
- Campana de notificaciones arriba: transferencias terminadas o con error,
|
||||
operaciones de archivos, desconexiones de la consola y versiones nuevas.
|
||||
|
||||
NOVEDADES 1.18.4
|
||||
- Arreglado: si dejabas la ventana minimizada o en segundo plano un rato, el
|
||||
programa se cerraba solo y al volver salía "Failed to fetch". Ahora solo se
|
||||
cierra cuando cierras la ventana de verdad.
|
||||
|
||||
NOVEDADES 1.18.3
|
||||
- Arreglado "Failed to fetch" y la cola que aparecía y desaparecía: en Windows
|
||||
podían quedar dos PS5 Directo escuchando en el mismo puerto (por ejemplo al
|
||||
abrirlo dos veces seguidas). Ahora solo puede haber uno abierto.
|
||||
- Si la conexión con el programa falla un instante, se reintenta sola.
|
||||
|
||||
NOVEDADES 1.18.2
|
||||
- Crea automáticamente un acceso directo "PS5 Directo" en el escritorio (y en "Enviar a")
|
||||
con el icono del programa. Si lo borras no vuelve a aparecer; si apuntaba a
|
||||
una copia vieja del programa, se corrige.
|
||||
- La barra de tareas enseña el icono nuevo mientras el programa está abierto.
|
||||
- Si se había quedado abierta una versión antigua (por ejemplo una copia vieja
|
||||
en otra carpeta), al abrir esta se cierra la vieja sola en vez de enseñarla.
|
||||
|
||||
NOVEDADES 1.18.1
|
||||
- Al borrar algo grande se ve cuánto se ha liberado y el tiempo que lleva, y el
|
||||
espacio libre del explorador se actualiza mientras tanto.
|
||||
|
||||
NOVEDADES 1.18
|
||||
- Pestaña "Espacio": gráfico de quesitos y lista de todo lo que ocupa sitio en
|
||||
la consola, con el nombre de cada aplicación y su tamaño. Marca contenido repetido
|
||||
(por ejemplo la carpeta y su versión convertida), envíos a medias y restos,
|
||||
y se pueden borrar desde ahí. También con discos USB/externos.
|
||||
- Descargas más rápidas: cada conexión se reutiliza para muchos trozos
|
||||
seguidos, como hacen los gestores de descargas.
|
||||
- La extensión pasa a PS5 Directo las cookies de ese alojamiento, el navegador
|
||||
y la página de origen: misma velocidad y permiso que en el navegador (sesión
|
||||
iniciada, cuenta premium) y menos errores 403. Las cookies no salen de tu PC
|
||||
ni se guardan en disco. Tras actualizar, en chrome://extensions recarga la
|
||||
extensión y acepta el permiso nuevo.
|
||||
- Carpetas: antes de enviar comprueba que lo que falta cabe en la PS5, y la
|
||||
velocidad ya no da saltos falsos al continuar un archivo a medias.
|
||||
- La IP de la PS5 se limpia sola si se escribe con espacios, ftp://, puerto o
|
||||
comas (antes daba "getaddrinfo failed").
|
||||
- Tras actualizar, una ventana te enseña las novedades.
|
||||
- Mejor diseño en ventanas estrechas y pequeños arreglos.
|
||||
|
||||
NOVEDADES 1.17
|
||||
- Enlaces ftp:// (un NAS u otro servidor): ftp://usuario:clave@nas/ruta/archivo.rar
|
||||
Funcionan como los enlaces normales, con reanudación.
|
||||
- Comprimidos .tar, .tar.gz, .tar.bz2 y .tar.xz: se descomprimen en el PC
|
||||
sobre la marcha (no hace falta 7-Zip ni BFpilot).
|
||||
- Las carpetas se envían con varias conexiones a la vez (Ajustes > Partes a
|
||||
la vez), más rápido con muchos archivos.
|
||||
- Pasos propios para los envíos de carpetas: Comparar → Enviar → Listo.
|
||||
|
||||
NOVEDADES 1.16
|
||||
- Pestaña "Archivos de la consola": explorador con tamaños, copiar, mover,
|
||||
renombrar, borrar, bajar al PC, permisos y subir aquí.
|
||||
- Enviar carpetas del PC, saltando lo que ya está y continuando lo cortado.
|
||||
- Botones "Elegir archivos…" y "Elegir carpeta…" (ventana de Windows).
|
||||
- Pestaña "Actividad" con el historial.
|
||||
|
||||
NOVEDADES 1.15
|
||||
- Inglés: botón "English" / "Español" arriba. La primera vez elige solo
|
||||
según el idioma de Windows. La extensión del navegador también.
|
||||
|
||||
NOVEDADES 1.14.1
|
||||
- Busca las actualizaciones en github.com/jstrdev/ps5directo sin tener
|
||||
que configurar nada.
|
||||
@@ -210,8 +500,8 @@ 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.
|
||||
la PS5). Una aplicación en formato carpeta puede ocupar varias veces el RAR
|
||||
(por ejemplo, 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.
|
||||
@@ -247,7 +537,8 @@ SI ALGO FALLA
|
||||
|
||||
ENLACES
|
||||
-------
|
||||
Funcionan los enlaces de descarga directa. Pixeldrain, Dropbox y algunos
|
||||
Funcionan los enlaces de descarga directa, y también los ftp:// de un NAS
|
||||
u otro servidor (ftp://usuario:clave@nas/ruta/archivo.rar). 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.
|
||||
|
||||
|
||||
@@ -9,13 +9,15 @@ 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
|
||||
If fso.FileExists(dir & "\python\pythonw.exe") Then
|
||||
py = """" & dir & "\python\pythonw.exe"""
|
||||
ElseIf 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"
|
||||
MsgBox "No encuentro Python. Descarga la version portable (trae Python) o instala Python 3 (marca 'Add to PATH').", 48, "PS5 Directo"
|
||||
Else
|
||||
sh.Run py & " """ & dir & "\app.py""" & args, 0, False
|
||||
End If
|
||||
@@ -1,29 +1,83 @@
|
||||
# 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ú.
|
||||
**English** · [Español](#español)
|
||||
|
||||
- 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.
|
||||
A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the console over FTP **while they download**, without storing them on your PC. It then extracts them, either on the console with BFpilot or on the PC with 7-Zip, and places apps in `/data/homebrew` so ShadowMountPlus shows them in the menu.
|
||||
|
||||
## 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.
|
||||
<sub>Demo with a sample homebrew app served from a NAS on the local network.</sub>
|
||||
|
||||
- ✅ Streams downloads straight to the console: nothing is saved on the PC
|
||||
- ✅ Works from download links, ftp:// links (e.g. a NAS) or files on your PC
|
||||
- ✅ Joins split parts (.part1, .part2… / .001, .002…) and extracts RAR, 7z, ZIP and tar (.gz/.bz2/.xz)
|
||||
- ✅ Extracts on the console (BFpilot) or on the PC (7-Zip, every file CRC-checked)
|
||||
- ✅ Fixes the file permissions that stop BFpilot-extracted content from launching (CE-107750-0)
|
||||
- ✅ Pause, resume and retry where it left off, even after a dropped connection
|
||||
- ✅ Built-in console file browser (works over plain FTP, BFpilot optional): right-click menu, shortcuts, move/copy to…, extract archives on the console, drag & drop uploads, download to PC
|
||||
- ✅ Notification bell: finished or failed transfers, disconnections and new versions
|
||||
- ✅ Storage view: a pie chart of what fills the console, by app name and size, flagging duplicates and leftovers you can delete
|
||||
- ✅ Send whole PC folders over several connections, skipping what's already there and resuming what got cut off
|
||||
- ✅ Watches free space so the console never fills up
|
||||
- ✅ Speed limit and built-in speed test
|
||||
- ✅ Download-manager style downloads: several reused connections per file, auto-backoff when a host complains
|
||||
- ✅ Optional browser extension: links arrive by themselves with your browser session (same speed and access as the browser)
|
||||
- ✅ Drag and drop, or "Send to" from Explorer
|
||||
- ✅ Welcome guide, one-click updates, English and Spanish
|
||||
- ✅ **Library tab**: covers, versions and the firmware each app needs, flagging what won't launch on your console (backport-aware), duplicates and incomplete folders
|
||||
- ✅ **Images and LZ4** with [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge) (optional): send an app as a single `.ffpkg` / `.ffpfsc` image or LZ4-packed, prepared on the PC
|
||||
- ✅ Asks before sending something that's already on the console (send anyway, replace or cancel)
|
||||
- ✅ Checks every part against its `.sfv` / `.md5` / `.sha1` while sending
|
||||
- ✅ "Make room": suggests what to delete when space runs out
|
||||
- ✅ Loads ftpsrv and BFpilot on the PS5 from the app (ELF loader, port 9021)
|
||||
- ✅ "Why is it slow?" network diagnostics (Wi-Fi band and signal, 100 Mb/s cables, VPNs, ping)
|
||||
- ✅ Open it from your phone with a QR code; schedule the queue and sleep or shut down the PC when it's done
|
||||
|
||||
## Requirements
|
||||
|
||||
- **PC:** Windows 10/11. Get the `-portable-windows.zip` (Python included, nothing to install), or the plain `.zip` with [Python 3.9+](https://www.python.org/downloads/) installed (tick "Add python.exe to PATH"). For extracting on the PC, also install [7-Zip](https://www.7-zip.org/).
|
||||
- **Jailbroken PS5:**
|
||||
- An FTP server, for example [ftpsrv](https://github.com/ps5-payload-dev/ftpsrv) on port 2121.
|
||||
- [BFpilot](https://github.com/ItsBlurf/BFpilot) on port 5905.
|
||||
- [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus).
|
||||
- Payloads can be loaded with [ps5-payload-manager](https://github.com/itsPLK/ps5-payload-manager).
|
||||
|
||||
## Uso
|
||||
## Getting started
|
||||
|
||||
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.
|
||||
1. Download the `-portable-windows.zip` (or the plain `.zip` if you have Python) from the latest [release](../../releases) and extract it anywhere.
|
||||
2. Run `Crear accesos directos.bat` ("create shortcuts"). It adds a desktop icon and a "Send to → PS5 Directo" entry.
|
||||
3. Open **PS5 Directo** from the desktop. The language follows Windows; switch it with the **English / Español** button at the top.
|
||||
4. Follow the welcome guide, enter your PS5's IP in Settings, then paste links or drop files.
|
||||
|
||||
La guía completa está en [LEEME.txt](LEEME.txt).
|
||||
> **About BFpilot and permissions:** up to at least v0.4.4, BFpilot's archive extractor leaves files without the execute bit, so the PS5 refuses to launch them (CE-107750-0). This also happens when you extract from BFpilot's own web UI. PS5 Directo fixes it automatically after every extraction. For content you already have, use **Settings → Fix permissions**.
|
||||
|
||||
## Aviso
|
||||
The full guide is in [README_EN.txt](README_EN.txt) (Spanish: [LEEME.txt](LEEME.txt)).
|
||||
|
||||
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).
|
||||
## Publishing a release
|
||||
|
||||
Bump `VERSION` in `app.py`, add the notes to `LEEME.txt` / `README_EN.txt`, then push a tag (`git tag v1.20.0 && git push --tags`). The GitHub Action builds both zips (plain and portable with Python) and creates the release.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Independent project, not affiliated with Sony or with the authors of the tools mentioned. It's a file-transfer tool: it includes no third-party content, no Sony keys or files, and no links to download sites. Use it only with content you have the right to use (your own backups and homebrew).
|
||||
|
||||
- It does not jailbreak the console or bypass any protection: it only copies files to a console you have already modified yourself.
|
||||
- No piracy: don't use it to copy or run software you don't own or aren't licensed to use. Requests for help getting such content are not answered.
|
||||
- You are responsible for how you use it. Modifying a console can void its warranty, break its terms of service or lose data: back up what matters.
|
||||
- Provided "as is", without warranty of any kind. "PlayStation" and "PS5" are trademarks of Sony Interactive Entertainment, used only to describe compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Español
|
||||
|
||||

|
||||
|
||||
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ú.
|
||||
|
||||
**Novedades 1.20:** pestaña Biblioteca con portadas y aviso de firmware, imágenes `.ffpkg`/`.ffpfsc` y LZ4 con [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge), pregunta si ya está en la consola, comprobación con `.sfv`/`.md5`, "Hacer sitio", cargar ftpsrv/BFpilot desde el programa, diagnóstico de red, acceso desde el móvil con QR y programar la cola.
|
||||
|
||||
**Requisitos:** Windows 10/11. Descarga el `-portable-windows.zip` (trae Python, no hay que instalar nada) o el `.zip` normal con [Python 3.9+](https://www.python.org/downloads/). Para descomprimir en el PC, [7-Zip](https://www.7-zip.org/). En la PS5: servidor FTP ([ftpsrv](https://github.com/ps5-payload-dev/ftpsrv)), [BFpilot](https://github.com/ItsBlurf/BFpilot) y [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus).
|
||||
|
||||
**Uso:** descarga el `.zip` de la última [release](../../releases), ejecuta `Crear accesos directos.bat` y abre **PS5 Directo** desde el escritorio. 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). No hace el jailbreak ni salta ninguna protección: solo copia archivos a una consola que ya has modificado tú. No se da ayuda para conseguir contenido sin licencia. Se ofrece tal cual, sin garantías, y lo que hagas con él es responsabilidad tuya.
|
||||
@@ -0,0 +1,431 @@
|
||||
PS5 DIRECTO 1.20.0 — USER GUIDE (English)
|
||||
=========================================
|
||||
|
||||
QUICK START (5 MINUTES)
|
||||
=======================
|
||||
You need: a PS5 that is already jailbroken (PS5 Directo doesn't do that)
|
||||
and a Windows 10/11 PC on the same network.
|
||||
|
||||
1. Download PS5Directo-X.Y.Z-portable-windows.zip from the GitHub Releases
|
||||
page (nothing else to install) and extract it, e.g. into Documents.
|
||||
2. Double-click "PS5 Directo.vbs". If Windows asks whether to open a file
|
||||
downloaded from the internet, click "Open".
|
||||
3. On the PS5, load your jailbreak and ShadowMountPlus as usual.
|
||||
4. The wizard asks for the PS5's IP: on the PS5, Settings > Network >
|
||||
View Connection Status > "IPv4 Address".
|
||||
5. It checks the FTP server and BFpilot. If one is red, click "Load" next to
|
||||
it. (With Y2JB Autoloader that doesn't work: load them from Payload
|
||||
Manager on the PS5 and click "Check again".)
|
||||
6. Paste a direct download link (or drag a file onto the window) and click
|
||||
"Add to queue". When it says "Done", it's in the PS5's menu.
|
||||
|
||||
Something wrong? Settings > "Full check" tells you what and how to fix it.
|
||||
|
||||
Note: PS5 Directo only copies files. Use it with content you have the
|
||||
right to use (your own backups and homebrew).
|
||||
|
||||
(The full guide follows; you don't need it to get started.)
|
||||
|
||||
|
||||
Paste links (or drag files from your PC) and the tool does the rest:
|
||||
|
||||
1. Downloads the files and sends them to the PS5 over FTP while they
|
||||
download (nothing is stored on your PC's disk).
|
||||
2. Joins split parts (.001, .002...) into a single file.
|
||||
3. Asks BFpilot to extract the RAR/7z/ZIP ON the console, trying the
|
||||
passwords it knows.
|
||||
4. Finds the app folder (the one with sce_sys) and puts it in
|
||||
/data/homebrew, where ShadowMountPlus picks it up automatically.
|
||||
5. Deletes the archives from the PS5.
|
||||
|
||||
While one transfer is extracting on the PS5, the next one in the queue is
|
||||
already being sent.
|
||||
|
||||
TABS
|
||||
- Transfers: the queue.
|
||||
- Console files: browse the PS5 like a file explorer. You see the size
|
||||
and date of every file (folders with "calculate"), and you can create
|
||||
folders, rename, copy or move (Copy/Move, then "Paste here" in the
|
||||
destination folder), delete, download to the PC (to Downloads\PS5
|
||||
Directo), set 777 permissions, and upload files or folders as-is into
|
||||
the current folder.
|
||||
Right-click a file or the empty space for the menu; shortcuts: Del,
|
||||
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A and Backspace. "Move to…" and
|
||||
"Copy to…" open a folder picker with recent destinations. If something
|
||||
with the same name already exists when pasting, you choose replace/merge,
|
||||
keep both or skip. "Extract…" unpacks a .rar/.7z/.zip that is already on
|
||||
the console. Drives (with free space) and quick places are on the left.
|
||||
You can drag files from the PC onto the open folder.
|
||||
Without BFpilot almost everything works over FTP (copying goes through
|
||||
the PC, slower); extracting and showing free space do need BFpilot.
|
||||
- Space: a pie chart of what takes up space on the console, with each
|
||||
app's name and size. It flags duplicate content (for example a folder and
|
||||
its version converted to an image), half-sent transfers and leftovers,
|
||||
and lets you delete them from there (it asks first). Needs BFpilot.
|
||||
Anything installed from the PS5 itself is shown but deleted on the console.
|
||||
- Activity: history of finished and failed transfers.
|
||||
|
||||
PC FOLDERS
|
||||
With "Choose folder…" (or by dragging the folder onto the icon) a whole
|
||||
folder is sent as-is. If it has sce_sys it goes to /data/homebrew as
|
||||
TITLEID-app. If part of it is already on the console (for example, an
|
||||
upload from another program that got cut off), it skips the files that
|
||||
are already identical and resumes the ones left half-way.
|
||||
|
||||
ALTERNATIVE: EXTRACT ON THE PC (7-Zip)
|
||||
In "Where to extract" choose "On the PC (7-Zip)" (or make it the default
|
||||
in Settings). Then:
|
||||
- Links are downloaded to the PC first (temporary folder in Settings;
|
||||
you need room for the RAR, not for the extracted content). Your own
|
||||
local files are used as they are and are never deleted.
|
||||
- 7-Zip extracts on the fly and the app sends each loose file to the
|
||||
PS5. Nothing extracted is written to the PC.
|
||||
- Every file is checked against its CRC before it's accepted.
|
||||
- On the PS5 it only takes the final size (no RAR alongside), and the
|
||||
exact size is known before starting.
|
||||
- Trade-off: everything travels uncompressed over Wi-Fi (can be 3-4
|
||||
times more than the RAR), so sending takes longer.
|
||||
- Pause/Retry continues from the file where it stopped.
|
||||
Requires 7-Zip (free, 7-zip.org). BFpilot is not needed.
|
||||
|
||||
|
||||
WHAT YOU NEED
|
||||
-------------
|
||||
This guide doesn't cover how to jailbreak: it assumes your PS5 is already
|
||||
jailbroken and that you know how to load payloads (.elf).
|
||||
|
||||
ON THE PC
|
||||
- Windows 10 or 11.
|
||||
- Python 3.9 or newer: https://www.python.org/downloads/
|
||||
When installing, tick "Add python.exe to PATH".
|
||||
- 7-Zip (only for "Extract on the PC"): https://www.7-zip.org/
|
||||
|
||||
ON THE PS5 (loaded as payloads, for example with Payload Manager:
|
||||
https://github.com/itsPLK/ps5-payload-manager )
|
||||
- An FTP server. The reference one is ftpsrv (port 2121):
|
||||
https://github.com/ps5-payload-dev/ftpsrv
|
||||
Many payload menus already include it. If yours uses another port,
|
||||
change it in Settings.
|
||||
- BFpilot (port 5905): extracts on the console, moves and deletes files.
|
||||
Get bfpilot.elf from: https://github.com/ItsBlurf/BFpilot/releases
|
||||
If it isn't loaded, the program waits until you load it. Not needed in
|
||||
"Extract on the PC" mode.
|
||||
- ShadowMountPlus: makes the apps in /data/homebrew appear on the PS5
|
||||
home screen: https://github.com/drakmor/ShadowMountPlus
|
||||
|
||||
To check that everything works: Settings > "Full check".
|
||||
|
||||
|
||||
ABOUT BFPILOT AND PERMISSIONS (important even if you use it on its own)
|
||||
-----------------------------------------------------------------------
|
||||
Up to at least version 0.4.4, when BFpilot extracts a RAR, 7z or ZIP it
|
||||
leaves the files without the execute permission, and the PS5 refuses to
|
||||
open the app (error CE-107750-0). It also happens when you extract from
|
||||
BFpilot's own web page, without PS5 Directo.
|
||||
- PS5 Directo fixes it automatically after every extraction.
|
||||
- For content already on your console: Settings > "Fix permissions".
|
||||
- By hand: in FileZilla, right-click the app folder > File permissions >
|
||||
777 > "Recurse into subdirectories".
|
||||
|
||||
|
||||
FIRST TIME
|
||||
----------
|
||||
Double-click "Crear accesos directos.bat" ("create shortcuts"). It adds:
|
||||
- A "PS5 Directo" icon on the desktop.
|
||||
- "Send to > PS5 Directo" in the right-click menu of your files.
|
||||
From then on, open PS5 Directo from the desktop icon: it runs in its own
|
||||
window, with no tabs and no black console window.
|
||||
|
||||
(If something fails to start, use "Iniciar PS5 Directo.bat" ("start PS5
|
||||
Directo"): same thing but with the black window, where errors are shown.
|
||||
Log when running without the window: %USERPROFILE%\.ps5directo\ps5directo.log)
|
||||
|
||||
The language follows Windows. Switch it anytime with the "English" /
|
||||
"Español" button at the top.
|
||||
|
||||
If you close the window with transfers running, they keep going in the
|
||||
background. Open PS5 Directo again to see them. The "Quit" button closes
|
||||
it completely (anything unfinished continues later with "Retry").
|
||||
|
||||
|
||||
HOW TO USE IT
|
||||
-------------
|
||||
1. Extract this folder anywhere you like.
|
||||
2. Open PS5 Directo from the desktop icon. A short welcome guide explains
|
||||
each part (you can see it again with the "Guide" button).
|
||||
3. The first time, Settings opens: enter your PS5's IP (on the PS5:
|
||||
Settings > Network > View Connection Status), click "Save" and then
|
||||
"Test connection". Both dots at the top should turn green.
|
||||
4. Paste the direct links (all parts together, in any order) and click
|
||||
"Add to queue".
|
||||
- If you know the password, type it. Otherwise it tries the ones in
|
||||
Settings and whatever is in brackets in the file name, like [WEB.COM].
|
||||
- A path to a file already on your PC also works.
|
||||
|
||||
5. FILES ON YOUR PC WITHOUT COPYING PATHS:
|
||||
- Drag one or more files onto the desktop icon.
|
||||
- Or right-click the files > Send to > PS5 Directo.
|
||||
If PS5 Directo is already open, they're added to its queue.
|
||||
- Or drop them on the app window: PS5 Directo finds them on your PC by
|
||||
name and size and puts their paths in the link box.
|
||||
|
||||
|
||||
PAUSE, LIMIT AND SPACE
|
||||
----------------------
|
||||
- "Pause" stops sending; "Resume" continues where it left off.
|
||||
- Settings > Speed limit (MB/s): to keep using the internet while it
|
||||
sends. Applies instantly. 0 = no limit.
|
||||
- Settings > Always keep free (GB, 25 by default): before starting it
|
||||
checks that what's left to send fits, and while sending or extracting it
|
||||
watches the free space. If it drops below that margin, it stops
|
||||
everything before the PS5 fills up. Free up space and click Retry.
|
||||
- Some file hosts limit connections: with them use just 2 per file, and
|
||||
fewer if the server starts returning errors.
|
||||
|
||||
|
||||
UPDATES AND EXTENSION
|
||||
---------------------
|
||||
- "Update" button (top): checks GitHub for a new version and installs it.
|
||||
When there is one, the button turns blue and shows the version. You can
|
||||
also install from a downloaded .zip. A copy of the previous version is
|
||||
kept in the .backup folder.
|
||||
- "Extension" button (top): opens the folder of the Chrome/Edge extension
|
||||
and its instructions. With it, when you click Download on a website, the
|
||||
link goes straight to PS5 Directo, no copy and paste:
|
||||
1. Open chrome://extensions (or edge://extensions).
|
||||
2. Turn on "Developer mode" (top right).
|
||||
3. "Load unpacked" and choose PS5 Directo's "extension" folder.
|
||||
Pass the captcha and click Download as usual: if it's a .rar/.7z/.zip/.pkg
|
||||
and PS5 Directo is open, the final link (after redirects) appears in the
|
||||
link box and the browser downloads nothing. When all parts are in, click
|
||||
"Add to queue". If PS5 Directo is closed, the download goes ahead
|
||||
normally. Turn it on and off from its icon.
|
||||
|
||||
|
||||
SPEED TEST
|
||||
----------
|
||||
On a paused or finished transfer, "Speed test" measures (about 30 s):
|
||||
download only, PC -> PS5 upload only, and both at once, and tells you
|
||||
what's to blame when it's slow. There's also a PC -> PS5 only test in
|
||||
Settings.
|
||||
|
||||
|
||||
MAINTENANCE
|
||||
-----------
|
||||
- Settings > Full check: tests FTP, writing, BFpilot, formats, 7-Zip and
|
||||
free space, and tells you what to do if something fails.
|
||||
- Settings > PS5 cleanup: finds leftovers in /data/ps5directo, shows how
|
||||
much they take and deletes them (it never touches anything outside that
|
||||
folder).
|
||||
- Warns if the same Title ID already exists in /data/homebrew or on a
|
||||
USB/external drive.
|
||||
- Destination: suggests /mnt/usbX/homebrew when drives are connected.
|
||||
- Windows notification when a transfer finishes or fails (accept the
|
||||
permission the first time).
|
||||
|
||||
|
||||
IF SOMETHING GOES WRONG
|
||||
-----------------------
|
||||
- The network or FTP drops: it retries by itself and continues where it
|
||||
left off.
|
||||
- You close the program midway: when you open it again, click "Retry".
|
||||
It continues from what was already on the console.
|
||||
- Wrong password: click "Retry" and type it. Nothing is downloaded again.
|
||||
- "Already exists": it never overwrites what you have. Delete or rename it
|
||||
and click "Retry".
|
||||
- Work files live in /data/ps5directo (outside the folders ShadowMountPlus
|
||||
scans).
|
||||
|
||||
|
||||
LINKS
|
||||
-----
|
||||
Direct download links work, and so do ftp:// links to a NAS or another
|
||||
server (ftp://user:password@nas/path/file.rar). Pixeldrain, Dropbox and some Mediafire and
|
||||
Google Drive links are converted automatically. Pages that require a
|
||||
captcha or a login don't work directly: copy the final download link, or
|
||||
use the browser extension.
|
||||
|
||||
.pkg files go to /data/pkg, to install them with ps5upload.
|
||||
Images (.exfat, .ffpkg, .ffpfsc) go to /data/homebrew.
|
||||
|
||||
Settings and queue are stored in %USERPROFILE%\.ps5directo
|
||||
|
||||
|
||||
LIBRARY
|
||||
-------
|
||||
The Library tab shows what's in /data/homebrew (and on USB drives) with its
|
||||
cover, name, Title ID, version, folder or image (.ffpkg, .ffpfsc, .exfat)
|
||||
and the firmware it needs. Set your PS5's firmware in Settings (e.g. 13.60)
|
||||
and the ones that won't launch are flagged in red. It also flags duplicates,
|
||||
DLC/updates and incomplete folders. Works with FTP alone; with BFpilot it
|
||||
also shows how much each folder takes. Firmware comes from param.json and,
|
||||
for backported titles (fakelib folder), from what the executables require.
|
||||
Every finished transfer is checked too.
|
||||
|
||||
IMAGES AND LZ4 (PS5 DUMP FORGE)
|
||||
-------------------------------
|
||||
"How to leave it on the PS5" defaults to "No conversion": left as it comes
|
||||
(archives are extracted; an .ffpkg, .exfat or .ffpfsc image or LZ4 content
|
||||
is sent untouched). You can also pick:
|
||||
- Plain folder: always ends up as a folder; images, .fpkg and LZ4 are
|
||||
unpacked on the PC.
|
||||
- .ffpkg image: recommended by ShadowMountPlus. Full speed.
|
||||
- .exfat or .ffpfs image: ShadowMountPlus mounts them too.
|
||||
- Compressed .ffpfsc image: 30-60% smaller, but read more slowly
|
||||
(150-250 MB/s); some titles may stutter.
|
||||
- .fpkg package: installs like a store app. Firmware 11.60 or lower only
|
||||
(kstuff, fpkg-enable and ppr-patch). Left in /data/pkg. For .fpkg and
|
||||
.ffpfsc you can pick the compression.
|
||||
- LZ4 (folder or inside an .ffpkg): AMPR titles only. Experimental: some
|
||||
titles crash when saving. Non-AMPR content is sent as a folder or plain
|
||||
.ffpkg.
|
||||
It's prepared on the PC with PS5 Dump Forge (github.com/quer3q/ps5-dump-forge,
|
||||
downloaded automatically the first time) and sent as a single file. Needs
|
||||
room on the PC for the image. .ffpkg/.ffpfsc images are not FPKG packages:
|
||||
they aren't installed, ShadowMountPlus mounts them. DLC and updates are
|
||||
sent as folders.
|
||||
|
||||
BEFORE SENDING
|
||||
--------------
|
||||
- If it's already on the console (same Title ID), the transfer
|
||||
stops and asks: send anyway, replace the copy there, or cancel.
|
||||
- Paste the .sfv / .md5 / .sha1 with the parts (or keep it next to them on
|
||||
the PC) and every part is checked while it's sent.
|
||||
- When there's no room, the error has a "Make room" button that suggests
|
||||
what to delete in the Space tab. Nothing is deleted without confirmation.
|
||||
|
||||
PAYLOADS, DIAGNOSTICS, PHONE AND SCHEDULE
|
||||
-----------------------------------------
|
||||
- Click the FTP / BFpilot dots (or Settings > Load payloads) to send ftpsrv
|
||||
or BFpilot to the PS5's ELF loader (port 9021, etaHEN or elfldr). The Y2JB
|
||||
Autoloader loader only accepts the console itself: use Payload Manager.
|
||||
- "Why is it slow?" checks Wi-Fi vs cable, band and signal, 100 Mb/s links,
|
||||
VPNs, different networks and the ping to the PS5.
|
||||
- The phone button shows a QR code to open PS5 Directo from your phone on
|
||||
the same network.
|
||||
- "Schedule" (queue): start at a set time and sleep or shut down the PC when
|
||||
done. The PC doesn't go to sleep while transfers are running.
|
||||
|
||||
PORTABLE VERSION
|
||||
----------------
|
||||
Each release has PS5Directo-X.Y.Z.zip (needs Python installed) and
|
||||
PS5Directo-X.Y.Z-portable-windows.zip, which includes Python: extract and run.
|
||||
|
||||
WHAT'S NEW
|
||||
----------
|
||||
1.20.0 - First-steps wizard (IP, connection check and loading what's
|
||||
missing, firmware) and a shorter, simpler guide.
|
||||
- New Library tab: what's on the console with cover, version, format
|
||||
and required firmware, flagging what won't launch on yours.
|
||||
- .ffpkg / .ffpfsc images and LZ4 with PS5 Dump Forge (optional):
|
||||
prepared on the PC and sent as a single file. Picked with cards under
|
||||
the destination, each explained, with the room needed on the PC; the
|
||||
first time it warns that converting takes PC space until it's done.
|
||||
A download that's already an image is sent as is or converted to
|
||||
the chosen format, and placed even without BFpilot.
|
||||
- New formats: plain folder, .exfat, .ffpfs and .fpkg (with a
|
||||
compression choice). Without 7-Zip, PS5 Dump Forge extracts it.
|
||||
- Formats are grouped (space savers first) and "Which one?"
|
||||
compares the pros and cons of each.
|
||||
- Time left (and rough finish time) when sending, extracting and
|
||||
converting. On the phone, a big alert with vibration and a beep
|
||||
when something finishes or fails (with the page open).
|
||||
- Library: LZ4-packed folders are tagged LZ4 (plus an LZ4 filter).
|
||||
- Fixed: resuming a transfer asked "Already on the console" about
|
||||
its own half-sent copy.
|
||||
- Fixed: the phone page was blank in some Android Chrome versions.
|
||||
- Open on phone: the page is sent compressed and the dialog shows
|
||||
"What arrives from the phone" to tell whether it gets there.
|
||||
- Firmware warning (set yours in Settings), backport-aware.
|
||||
- Asks before sending something that's already on the console.
|
||||
- .sfv / .md5 / .sha1 checks while sending.
|
||||
- "Make room": suggests what to delete when space runs out.
|
||||
- Load ftpsrv and BFpilot on the PS5 from the app (port 9021).
|
||||
- "Why is it slow?" network diagnostics.
|
||||
- Open from your phone with a QR code.
|
||||
- Schedule the queue, sleep or shut down when done, no sleep while
|
||||
sending.
|
||||
- Portable version with Python included.
|
||||
- Fixed: with FTP only (no BFpilot), ftpsrv returned the console's root
|
||||
when listing a folder; it affected the file browser and folder uploads.
|
||||
- Phone access: a button to allow it in Windows Firewall if the page
|
||||
stays blank.
|
||||
1.19.2 - Fixed "504 Option not supported" in Console files when BFpilot is
|
||||
not loaded: the file browser now lists folders with ftpsrv alone
|
||||
(and with FTP servers that have no MLSD).
|
||||
1.19.1 - Fixed: the queue stopped updating by itself (since 1.18.3), so
|
||||
"Remove" seemed to do nothing and then said "no existe". The file
|
||||
browser now relists the open folder when an upload to it finishes.
|
||||
Fixed "Download to PC" for apps: ftpsrv decrypted eboot.bin/.prx/
|
||||
.sprx on the fly, so downloads came out incomplete. Files are now
|
||||
transferred exactly as stored (this also makes size comparisons
|
||||
when resuming folders correct). Thanks to RichyMX for finding it.
|
||||
1.19 - Renewed file browser: right-click menu, keyboard shortcuts,
|
||||
clickable path, drives and places on the left, "Move to…" / "Copy
|
||||
to…" with recent destinations, a choice when pasting over existing
|
||||
items (replace, merge, keep both or skip), "Extract…" for archives
|
||||
already on the console, and dragging files from the PC onto the
|
||||
open folder.
|
||||
- The file browser no longer needs BFpilot for most things: rename,
|
||||
move, delete, new folder, permissions, sizes and copy work over FTP.
|
||||
- Notification bell at the top: finished or failed transfers, file
|
||||
operations, console disconnections and new versions.
|
||||
1.18.4 - Fixed: leaving the window minimized or in the background for a while
|
||||
made the program close itself, showing "Failed to fetch" on return.
|
||||
Now it only closes when you actually close the window.
|
||||
1.18.3 - Fixed "Failed to fetch" and the queue appearing and disappearing: on
|
||||
Windows two PS5 Directo could end up listening on the same port (e.g.
|
||||
when opened twice in a row). Now only one can be open. Brief
|
||||
connection hiccups are retried automatically.
|
||||
1.18.2 - Creates a "PS5 Directo" shortcut on the desktop (and in "Send to")
|
||||
with the program's icon. If you delete it, it won't come back; if it
|
||||
pointed to an old copy, it's fixed. The taskbar shows the new icon.
|
||||
If an old version was still open (e.g. an old copy in another folder),
|
||||
it's closed automatically instead of being shown.
|
||||
1.18.1 - When deleting something big you see how much has been freed and how
|
||||
long it has taken, and the browser's free space updates meanwhile.
|
||||
1.18 - "Space" tab: a pie chart and list of everything that takes up space
|
||||
on the console, by app name and size. It flags duplicate content (for
|
||||
example a folder and its converted version), half-sent transfers and
|
||||
leftovers, and deletes them from there. USB/external drives too.
|
||||
- Faster downloads: each connection is reused for many chunks in a row,
|
||||
like download managers do.
|
||||
- The extension passes that host's cookies, browser and source page to
|
||||
PS5 Directo: same speed and access as in the browser (logged-in or
|
||||
premium) and fewer 403 errors. Cookies never leave your PC and are
|
||||
never saved to disk. After updating, reload the extension in
|
||||
chrome://extensions and accept the new permission.
|
||||
- Folders: checks that what's left to send fits on the PS5, and the
|
||||
speed no longer shows false jumps when resuming a half-sent file.
|
||||
- The PS5 IP is cleaned up if typed with spaces, ftp://, a port or
|
||||
commas (it used to fail with "getaddrinfo failed").
|
||||
- After updating, a window shows what's new.
|
||||
- Better layout on narrow windows and small fixes.
|
||||
1.17 ftp:// links (a NAS or other server) with resume. .tar/.tar.gz/.tar.bz2/
|
||||
.tar.xz extracted on the PC (no 7-Zip needed). Folders upload over several
|
||||
connections at once. Own steps for folder uploads.
|
||||
1.16 "Console files" tab (file browser: sizes, copy, move, rename, delete,
|
||||
download to PC, permissions, upload here). Send PC folders with
|
||||
resume. "Choose files…/Choose folder…" buttons. "Activity" tab.
|
||||
1.15 English interface (auto-detected; English/Español button). The
|
||||
browser extension also follows the browser's language.
|
||||
1.14 Welcome guide on first launch ("Guide" button to see it again).
|
||||
Updates are checked on GitHub automatically.
|
||||
1.13 New icon and background.
|
||||
1.12 "Update" and "Extension" buttons on the main screen. Asks for the
|
||||
PS5's IP on first launch.
|
||||
1.11 IMPORTANT FIX: content extracted by BFpilot didn't launch
|
||||
(CE-107750-0) because of missing execute permissions. PS5 Directo now
|
||||
sets 777 when placing it. New Settings > "Fix permissions".
|
||||
1.10 New option: extract on the PC with 7-Zip and send the content already
|
||||
extracted, verified file by file.
|
||||
|
||||
|
||||
DISCLAIMER
|
||||
----------
|
||||
PS5 Directo is an independent program, not affiliated with Sony or with
|
||||
the authors of BFpilot, ftpsrv or ShadowMountPlus. It's a file-transfer
|
||||
tool: it includes no third-party content, no Sony keys or files, and no
|
||||
links to download sites. Use it only with content you have the right to
|
||||
use (your own backups and homebrew). Everyone is responsible for what they
|
||||
transfer.
|
||||
@@ -0,0 +1,84 @@
|
||||
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
|
||||
// PS5 Directo - captura de enlaces.
|
||||
// Cuando el navegador empieza a descargar un archivo comprimido o paquete (.rar, .7z, .zip, .pkg...),
|
||||
// manda el enlace FINAL (despues de redirecciones) a PS5 Directo y cancela la descarga.
|
||||
// Si PS5 Directo no esta abierto, no toca nada: la descarga sigue normal.
|
||||
|
||||
const WANTED_FILE = /\.(part\d+\.rar|rar|r\d{2,3}|7z(\.\d{3})?|zip|z\d{2}|\d{3}|pkg|exfat|ffpkg|ffpfsc?|ufs)$/i;
|
||||
const PORTS = [8777, 8778, 8779];
|
||||
|
||||
async function settings() {
|
||||
return chrome.storage.local.get({ enabled: true, captured: 0 });
|
||||
}
|
||||
|
||||
async function findApp() {
|
||||
for (const port of PORTS) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j.app === "PS5 Directo") return { port, version: j.version };
|
||||
}
|
||||
} catch (e) { /* no esta en ese puerto */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileName(item) {
|
||||
const f = (item.filename || "").split(/[\\/]/).pop();
|
||||
if (f) return f;
|
||||
try { return decodeURIComponent(new URL(item.finalUrl || item.url).pathname.split("/").pop()); }
|
||||
catch (e) { return ""; }
|
||||
}
|
||||
|
||||
async function handle(item) {
|
||||
const cfg = await settings();
|
||||
if (!cfg.enabled) return;
|
||||
const url = item.finalUrl || item.url;
|
||||
if (!/^https?:/i.test(url)) return;
|
||||
const name = fileName(item);
|
||||
const path = url.split("?")[0];
|
||||
if (!WANTED_FILE.test(name) && !WANTED_FILE.test(path)) return; // no es un archivo que nos interese
|
||||
const app = await findApp();
|
||||
if (!app) { // PS5 Directo cerrado: no tocar
|
||||
chrome.action.setBadgeText({ text: "off" });
|
||||
return;
|
||||
}
|
||||
// Como los gestores de descargas: pasamos las cookies de ese alojamiento (sesion, cuenta
|
||||
// premium), el navegador y la pagina de origen, para que el servidor nos trate igual.
|
||||
// Solo van a PS5 Directo en este PC (127.0.0.1), a ningun otro sitio.
|
||||
const cookies = {};
|
||||
for (const u of [url, item.url]) {
|
||||
try {
|
||||
const host = new URL(u).host.toLowerCase();
|
||||
if (host in cookies) continue;
|
||||
const list = await chrome.cookies.getAll({ url: u });
|
||||
cookies[host] = list.map(c => `${c.name}=${c.value}`).join("; ");
|
||||
} catch (e) { /* sin permiso o enlace raro: sin cookies */ }
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${app.port}/api/ext/capture`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, original: item.url, name, cookies,
|
||||
ua: navigator.userAgent, referrer: item.referrer || "" })
|
||||
});
|
||||
if (!r.ok) return;
|
||||
} catch (e) { return; }
|
||||
try { await chrome.downloads.cancel(item.id); } catch (e) {}
|
||||
try { await chrome.downloads.erase({ id: item.id }); } catch (e) {}
|
||||
const n = (cfg.captured || 0) + 1;
|
||||
await chrome.storage.local.set({ captured: n, last: name });
|
||||
chrome.action.setBadgeBackgroundColor({ color: "#2563eb" });
|
||||
chrome.action.setBadgeText({ text: String(n % 1000) });
|
||||
chrome.notifications.create({
|
||||
type: "basic", iconUrl: "icon128.png", title: EN ? "Sent to PS5 Directo" : "Enviado a PS5 Directo",
|
||||
message: (name || (EN ? "Link" : "Enlace")) + (EN ? " — not downloaded to the PC." : " — no se descarga en el PC.")
|
||||
});
|
||||
}
|
||||
|
||||
// Cuando ya se sabe el nombre del archivo y el enlace final (tras las redirecciones)
|
||||
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
|
||||
suggest(); // no cambiamos el nombre
|
||||
handle(item);
|
||||
});
|
||||
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 275 KiB |
@@ -10,9 +10,11 @@ Solo usa la biblioteca estandar de Python 3.9+.
|
||||
"""
|
||||
|
||||
import ftplib
|
||||
import http.client
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
@@ -40,7 +42,12 @@ class PipeError(Exception):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARCHIVE_RE = re.compile(
|
||||
r"\.(part\d+\.rar|rar|r\d{2,3}|7z|7z\.\d{3}|zip|z\d{2})$", re.I)
|
||||
r"\.(part\d+\.rar|rar|r\d{2,3}|7z|7z\.\d{3}|zip|z\d{2}|tar|tar\.gz|tgz|tar\.bz2|tbz2?|tar\.xz|txz)$", re.I)
|
||||
TAR_RE = re.compile(r"\.(tar|tar\.gz|tgz|tar\.bz2|tbz2?|tar\.xz|txz)$", re.I)
|
||||
|
||||
|
||||
def is_tar(name):
|
||||
return bool(TAR_RE.search(name or ""))
|
||||
SPLIT_RAW_RE = re.compile(r"^(?P<base>.+)\.(?P<num>\d{3})$")
|
||||
IMAGE_EXTS = (".exfat", ".ffpkg", ".ffpfs", ".ffpfsc", ".ufs")
|
||||
|
||||
@@ -48,6 +55,7 @@ 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"\.tar\.(gz|bz2|xz)$", r"\.(tgz|tbz2?|txz|tar)$",
|
||||
r"\.rar$", r"\.7z$", r"\.zip$", r"\.\d{3}$"):
|
||||
new = re.sub(pat, "", name, flags=re.I)
|
||||
if new != name:
|
||||
@@ -63,7 +71,8 @@ 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$"):
|
||||
r"\.z\d{2}$", r"\.tar\.(gz|bz2|xz)$", r"\.(tgz|tbz2?|txz|tar)$",
|
||||
r"\.rar$", r"\.7z$", r"\.zip$"):
|
||||
new = re.sub(pat, "", n, flags=re.I)
|
||||
if new != n:
|
||||
return new.lower()
|
||||
@@ -150,20 +159,125 @@ HONEST_UA = "PS5Directo/1.0 (download manager)"
|
||||
_HOST_MODE = {}
|
||||
|
||||
|
||||
def _open(url, headers=None, timeout=30):
|
||||
# Lo que la extension del navegador nos cuenta de cada alojamiento: sus cookies (sesion,
|
||||
# cuenta premium), el navegador y la pagina de origen. Asi el servidor nos trata igual que
|
||||
# al navegador, como hacen los gestores de descargas. Solo en memoria, nunca en disco.
|
||||
_BROWSER = {}
|
||||
_BROWSER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def set_browser_headers(url, cookie="", ua="", referer=""):
|
||||
host = urllib.parse.urlparse(url).netloc.lower()
|
||||
if not host:
|
||||
return
|
||||
info = {}
|
||||
if cookie:
|
||||
info["Cookie"] = cookie[:8000]
|
||||
if ua:
|
||||
info["User-Agent"] = ua[:500]
|
||||
if referer and re.match(r"^https?://", referer, re.I):
|
||||
info["Referer"] = referer[:2000]
|
||||
with _BROWSER_LOCK:
|
||||
if info:
|
||||
_BROWSER[host] = info
|
||||
else:
|
||||
_BROWSER.pop(host, None)
|
||||
|
||||
|
||||
def _headers(url, extra=None):
|
||||
pu = urllib.parse.urlparse(url)
|
||||
if _HOST_MODE.get(pu.netloc) == "honest":
|
||||
host = pu.netloc.lower()
|
||||
with _BROWSER_LOCK:
|
||||
browser = dict(_BROWSER.get(host) or {})
|
||||
if _HOST_MODE.get(pu.netloc) == "honest" and not browser:
|
||||
h = {"User-Agent": HONEST_UA, "Accept": "*/*"}
|
||||
else:
|
||||
h = {"User-Agent": UA, "Accept": "*/*", "Accept-Language": "es-ES,es;q=0.9,en;q=0.8"}
|
||||
if pu.scheme in ("http", "https") and pu.netloc:
|
||||
h["Referer"] = "%s://%s/" % (pu.scheme, pu.netloc)
|
||||
if headers:
|
||||
h.update(headers)
|
||||
req = urllib.request.Request(url, headers=h)
|
||||
h.update(browser)
|
||||
if extra:
|
||||
h.update(extra)
|
||||
return h
|
||||
|
||||
|
||||
def _open(url, headers=None, timeout=30):
|
||||
req = urllib.request.Request(url, headers=_headers(url, headers))
|
||||
return urllib.request.urlopen(req, timeout=timeout, context=_SSL)
|
||||
|
||||
|
||||
class _KeepAlive:
|
||||
"""Una conexion HTTP que se reutiliza para muchos trozos seguidos (como los gestores
|
||||
de descargas): sin repetir el saludo TCP/TLS ni el arranque lento de TCP en cada trozo.
|
||||
Si hay proxy o el servidor redirige, se usa la via normal."""
|
||||
|
||||
def __init__(self):
|
||||
self.conn = None
|
||||
self.key = None
|
||||
self.used = False
|
||||
self.off = False
|
||||
|
||||
@staticmethod
|
||||
def usable(url):
|
||||
scheme = urllib.parse.urlparse(url).scheme.lower()
|
||||
if scheme not in ("http", "https"):
|
||||
return False
|
||||
try:
|
||||
return not urllib.request.getproxies().get(scheme)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def open(self, url, headers, timeout):
|
||||
if self.off:
|
||||
return None
|
||||
pu = urllib.parse.urlparse(url)
|
||||
key = (pu.scheme.lower(), pu.netloc)
|
||||
if self.conn is None or self.key != key:
|
||||
self.close()
|
||||
if key[0] == "https":
|
||||
self.conn = http.client.HTTPSConnection(pu.netloc, timeout=timeout, context=_SSL)
|
||||
else:
|
||||
self.conn = http.client.HTTPConnection(pu.netloc, timeout=timeout)
|
||||
self.key = key
|
||||
path = pu.path or "/"
|
||||
if pu.query:
|
||||
path += "?" + pu.query
|
||||
h = _headers(url, headers)
|
||||
h["Connection"] = "keep-alive"
|
||||
try:
|
||||
self.conn.request("GET", path, headers=h)
|
||||
r = self.conn.getresponse()
|
||||
except Exception:
|
||||
stale = self.used # el servidor cerro la conexion mientras esperaba:
|
||||
self.close() # es normal, se abre otra sin contarlo como fallo
|
||||
if not stale:
|
||||
raise
|
||||
return self.open(url, headers, timeout)
|
||||
self.used = True
|
||||
if r.status >= 300:
|
||||
hdrs = r.headers
|
||||
try:
|
||||
r.read(64 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
self.close()
|
||||
if r.status < 400:
|
||||
self.off = True # redirige en cada trozo: que lo lleve urllib
|
||||
return None
|
||||
raise urllib.error.HTTPError(url, r.status, r.reason, hdrs, None)
|
||||
return r
|
||||
|
||||
def close(self):
|
||||
if self.conn is not None:
|
||||
try:
|
||||
self.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.conn = None
|
||||
self.key = None
|
||||
self.used = False
|
||||
|
||||
|
||||
def resolve_url(url):
|
||||
"""Convierte paginas de algunos alojamientos en el enlace directo (lo que se pueda)."""
|
||||
url = url.strip()
|
||||
@@ -281,15 +395,20 @@ def _single(url, cancel, meter):
|
||||
yield data
|
||||
|
||||
|
||||
def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None):
|
||||
def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None, ka=None):
|
||||
want = b - a + 1
|
||||
last = None
|
||||
rng = {"Range": "bytes=%d-%d" % (a, b)}
|
||||
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:
|
||||
r = ka.open(url, rng, 25) if ka is not None else None
|
||||
reused = r is not None
|
||||
if r is None:
|
||||
r = _open(url, rng, timeout=25)
|
||||
try:
|
||||
if r.status != 206:
|
||||
raise PipeError("el servidor no respeto el rango pedido")
|
||||
while len(got) < want:
|
||||
@@ -300,8 +419,19 @@ def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None):
|
||||
break
|
||||
got += piece
|
||||
meter(len(piece))
|
||||
except BaseException:
|
||||
if reused:
|
||||
ka.close()
|
||||
raise
|
||||
finally:
|
||||
if not reused:
|
||||
r.close()
|
||||
if len(got) == want:
|
||||
if reused and r.will_close:
|
||||
ka.close() # el servidor no deja reutilizarla
|
||||
return bytes(got)
|
||||
if reused:
|
||||
ka.close()
|
||||
last = "respuesta corta (%d de %d bytes)" % (len(got), want)
|
||||
except (Cancelled, PipeError):
|
||||
raise
|
||||
@@ -384,7 +514,17 @@ def _ranged(url, start, end, cfg, cancel, meter):
|
||||
state["trouble_t"] = now
|
||||
log("El servidor va con problemas (%s): bajo a %d conexiones" % (kind, state["allowed"]))
|
||||
|
||||
use_ka = _KeepAlive.usable(url)
|
||||
|
||||
def worker():
|
||||
ka = _KeepAlive() if use_ka else None
|
||||
try:
|
||||
_worker(ka)
|
||||
finally:
|
||||
if ka is not None:
|
||||
ka.close()
|
||||
|
||||
def _worker(ka):
|
||||
while True:
|
||||
with cond:
|
||||
# recuperar conexiones si lleva un rato sin problemas
|
||||
@@ -407,18 +547,18 @@ def _ranged(url, start, end, cfg, cancel, meter):
|
||||
started[i] = time.time()
|
||||
state["active"] += 1
|
||||
try:
|
||||
fetch(i)
|
||||
fetch(i, ka=ka)
|
||||
finally:
|
||||
with cond:
|
||||
state["active"] -= 1
|
||||
cond.notify_all()
|
||||
|
||||
def fetch(i, hedge=False):
|
||||
def fetch(i, hedge=False, ka=None):
|
||||
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)
|
||||
data = _fetch_range(url, a, b, cancel, meter, on_trouble=trouble, ka=ka)
|
||||
except Exception as e:
|
||||
with cond:
|
||||
# si es la copia de rescate, o el original ya llego, no es grave
|
||||
@@ -495,13 +635,393 @@ class FileSource:
|
||||
yield data
|
||||
|
||||
|
||||
class FolderSource:
|
||||
"""Una carpeta del PC: se manda entera, archivo a archivo, conservando la estructura."""
|
||||
kind = "folder"
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = os.path.abspath(path.strip().strip('"'))
|
||||
self.original = self.path
|
||||
self.name = safe_name(os.path.basename(self.path.rstrip("\\/")) or "carpeta")
|
||||
self.size = -1
|
||||
self.ranges = True
|
||||
self.files = [] # [(ruta relativa con /, tamano)]
|
||||
self.dirs = []
|
||||
|
||||
def probe(self):
|
||||
if not os.path.isdir(self.path):
|
||||
raise PipeError("No existe la carpeta: %s" % self.path)
|
||||
files, dirs = [], []
|
||||
for root, ds, fs in os.walk(self.path):
|
||||
ds.sort()
|
||||
rel_root = os.path.relpath(root, self.path).replace("\\", "/")
|
||||
rel_root = "" if rel_root == "." else rel_root
|
||||
for d in ds:
|
||||
dirs.append((rel_root + "/" + d).lstrip("/"))
|
||||
for f in sorted(fs):
|
||||
full = os.path.join(root, f)
|
||||
try:
|
||||
files.append(((rel_root + "/" + f).lstrip("/"), os.path.getsize(full)))
|
||||
except OSError:
|
||||
pass
|
||||
self.files, self.dirs = files, dirs
|
||||
self.size = sum(sz for _, sz in files)
|
||||
return self
|
||||
|
||||
|
||||
class FtpSource:
|
||||
"""Un archivo en otro servidor FTP (un NAS, por ejemplo): ftp://usuario:clave@host:puerto/ruta"""
|
||||
kind = "url"
|
||||
|
||||
def __init__(self, url):
|
||||
self.original = url.strip()
|
||||
u = urllib.parse.urlparse(self.original)
|
||||
self.host = u.hostname or ""
|
||||
self.port = u.port or 21
|
||||
self.user = urllib.parse.unquote(u.username or "anonymous")
|
||||
self.pw = urllib.parse.unquote(u.password or "")
|
||||
self.path = urllib.parse.unquote(u.path or "/")
|
||||
self.name = safe_name(posixpath.basename(self.path) or "archivo")
|
||||
self.size = -1
|
||||
self.ranges = True
|
||||
|
||||
def _connect(self):
|
||||
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")
|
||||
return f
|
||||
|
||||
def probe(self):
|
||||
try:
|
||||
f = self._connect()
|
||||
except Exception as e:
|
||||
raise PipeError("No puedo conectar con el FTP %s:%s (%s)" % (self.host, self.port, e))
|
||||
try:
|
||||
s = Ftp.size(f, self.path)
|
||||
if s is None:
|
||||
raise PipeError("No existe en el FTP: %s" % self.path)
|
||||
self.size = s
|
||||
finally:
|
||||
try:
|
||||
f.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return self
|
||||
|
||||
def stream(self, offset, cfg, cancel, meter):
|
||||
tries = 0
|
||||
while True:
|
||||
f = None
|
||||
try:
|
||||
f = self._connect()
|
||||
conn = f.transfercmd("RETR " + self.path, rest=offset or None)
|
||||
with conn:
|
||||
while True:
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
data = conn.recv(MiB)
|
||||
if not data:
|
||||
break
|
||||
offset += len(data)
|
||||
tries = 0
|
||||
meter(len(data))
|
||||
yield data
|
||||
try:
|
||||
f.voidresp()
|
||||
except Exception:
|
||||
pass
|
||||
if self.size >= 0 and offset < self.size:
|
||||
raise PipeError("el FTP corto la descarga")
|
||||
return
|
||||
except (Cancelled, GeneratorExit):
|
||||
raise
|
||||
except Exception as e:
|
||||
tries += 1
|
||||
if tries > int(cfg.get("retries", 8)):
|
||||
raise PipeError("Fallo al leer del FTP %s: %s" % (self.host, e))
|
||||
time.sleep(min(30, 2 * tries))
|
||||
finally:
|
||||
if f is not None:
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def make_source(text):
|
||||
text = text.strip().strip('"')
|
||||
if re.match(r"^https?://", text, re.I):
|
||||
return UrlSource(text)
|
||||
if re.match(r"^ftp://", text, re.I):
|
||||
return FtpSource(text)
|
||||
if os.path.isdir(text):
|
||||
return FolderSource(text)
|
||||
return FileSource(text)
|
||||
|
||||
|
||||
_MONTHS = {m: i for i, m in enumerate(("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep",
|
||||
"oct", "nov", "dec"), 1)}
|
||||
|
||||
|
||||
def _list_mode(perm):
|
||||
"""'drwxr-xr-x' -> 0o755 (None si no se entiende)."""
|
||||
if len(perm) < 10:
|
||||
return None
|
||||
m = 0
|
||||
for i, ch in enumerate(perm[1:10]):
|
||||
if ch not in "-":
|
||||
m |= 1 << (8 - i)
|
||||
return m
|
||||
|
||||
|
||||
def _list_mtime(mon, day, yt):
|
||||
import time as _t
|
||||
try:
|
||||
mo = _MONTHS[mon[:3].lower()]
|
||||
d = int(day)
|
||||
if ":" in yt:
|
||||
hh, mm = (int(x) for x in yt.split(":"))
|
||||
now = _t.localtime()
|
||||
y = now.tm_year if (mo, d) <= (now.tm_mon, now.tm_mday) else now.tm_year - 1
|
||||
else:
|
||||
y, hh, mm = int(yt), 0, 0
|
||||
return int(_t.mktime((y, mo, d, hh, mm, 0, 0, 0, -1)))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ftp_listdir(f, path):
|
||||
"""[(nombre, {"type": "file"|"dir", "size": int, "mtime": int|None, "mode": int|None})].
|
||||
Usa MLSD tal cual (sin OPTS MLST: ftpsrv responde "504 Option not supported") y, si el
|
||||
servidor no tiene MLSD, LIST. Lanza ftplib.error_perm 550 si la carpeta no existe."""
|
||||
import time as _t
|
||||
out = []
|
||||
# Primero se entra en la carpeta y se lista "lo de aqui": ftpsrv no hace caso a la ruta de
|
||||
# MLSD/LIST y devolvia la carpeta actual (la raiz de la PS5). Si no existe, CWD da 550.
|
||||
f.cwd(path)
|
||||
try:
|
||||
lines = []
|
||||
f.retrlines("MLSD", lines.append)
|
||||
for ln in lines:
|
||||
facts_s, _, name = ln.partition(" ")
|
||||
if not name or name in (".", ".."):
|
||||
continue
|
||||
facts = {}
|
||||
for kv in facts_s.split(";"):
|
||||
k, _, v = kv.partition("=")
|
||||
if k:
|
||||
facts[k.lower()] = v
|
||||
t = facts.get("type", "").lower()
|
||||
if t not in ("file", "dir"):
|
||||
continue
|
||||
mt = facts.get("modify")
|
||||
try:
|
||||
mt = int(_t.mktime(_t.strptime(mt[:14], "%Y%m%d%H%M%S"))) if mt else None
|
||||
except Exception:
|
||||
mt = None
|
||||
mode = facts.get("unix.mode")
|
||||
try:
|
||||
mode = int(mode, 8) if mode else None
|
||||
except ValueError:
|
||||
mode = None
|
||||
try:
|
||||
size = int(facts.get("size") or 0)
|
||||
except ValueError:
|
||||
size = 0
|
||||
out.append((name, {"type": t, "size": size, "mtime": mt, "mode": mode}))
|
||||
return out
|
||||
except ftplib.error_perm as e:
|
||||
if str(e).startswith("550"):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
f.voidcmd("TYPE I")
|
||||
except Exception:
|
||||
pass
|
||||
# servidor sin MLSD: LIST al estilo "ls -l"
|
||||
lines = []
|
||||
try:
|
||||
f.retrlines("LIST", lines.append)
|
||||
finally:
|
||||
try:
|
||||
f.voidcmd("TYPE I")
|
||||
except Exception:
|
||||
pass
|
||||
for ln in lines:
|
||||
parts = ln.split(None, 8)
|
||||
if len(parts) < 9 or parts[8] in (".", ".."):
|
||||
continue
|
||||
kind = "dir" if ln.startswith("d") else ("file" if ln.startswith("-") else None)
|
||||
if not kind:
|
||||
continue
|
||||
try:
|
||||
size = int(parts[4])
|
||||
except ValueError:
|
||||
size = 0
|
||||
out.append((parts[8], {"type": kind, "size": size, "mtime": _list_mtime(parts[5], parts[6], parts[7]),
|
||||
"mode": _list_mode(parts[0])}))
|
||||
return out
|
||||
|
||||
|
||||
def ftp_sizes(f, path):
|
||||
"""{nombre: tamano} de los archivos de una carpeta remota (None si no existe)."""
|
||||
try:
|
||||
return _ftp_sizes(f, path)
|
||||
finally:
|
||||
try:
|
||||
f.voidcmd("TYPE I") # MLSD/LIST dejan la conexion en ASCII y REST fallaria
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ftp_sizes(f, path):
|
||||
try:
|
||||
ents = ftp_listdir(f, path)
|
||||
except ftplib.error_perm:
|
||||
return None
|
||||
return {n: x["size"] for n, x in ents if x["type"] == "file"}
|
||||
|
||||
|
||||
def upload_tree(cfg, src, remote_root, cancel, log, progress, workers=None, on_plan=None):
|
||||
"""Manda una FolderSource a remote_root. Salta los archivos que ya estan con el mismo
|
||||
tamano, continua los que se cortaron (mas pequenos) y vuelve a mandar los demas.
|
||||
Usa varias conexiones a la vez (cfg parallel_files). progress(bytes_hechos, ruta_actual).
|
||||
on_plan(bytes_que_faltan), si se da, se llama tras comparar y antes de enviar nada
|
||||
(para comprobar el espacio libre); puede lanzar una excepcion para no seguir.
|
||||
Devuelve (enviados, saltados, continuados)."""
|
||||
import queue
|
||||
by_dir = {}
|
||||
for rel, size in src.files:
|
||||
d, _, n = rel.rpartition("/")
|
||||
by_dir.setdefault(d, []).append((n, size, rel))
|
||||
workers = max(1, min(4, int(workers or cfg.get("parallel_files", 2) or 1)))
|
||||
lock = threading.Lock()
|
||||
st = {"done": 0, "sent": 0, "skipped": 0, "resumed": 0, "err": None}
|
||||
tasks = queue.Queue()
|
||||
# 1) una sola conexion: crear carpetas y ver que hay ya en la consola
|
||||
w = _FtpWriter(cfg, cancel, log)
|
||||
try:
|
||||
w.mkdir(remote_root)
|
||||
for d in src.dirs:
|
||||
w.mkdir(remote_root + "/" + d)
|
||||
for d in sorted(by_dir):
|
||||
rdir = remote_root + ("/" + d if d else "")
|
||||
w.mkdir(rdir)
|
||||
have = w._retry(lambda: ftp_sizes(w.f, rdir)) or {}
|
||||
for name, size, rel in by_dir[d]:
|
||||
got = have.get(name)
|
||||
if got == size:
|
||||
st["skipped"] += 1
|
||||
st["done"] += size
|
||||
continue
|
||||
offset = got if (got is not None and 0 < got < size) else 0
|
||||
st["done"] += offset # lo que ya habia en la consola cuenta como hecho
|
||||
tasks.put((rdir + "/" + name, rel, size, offset))
|
||||
finally:
|
||||
w.close()
|
||||
if on_plan is not None:
|
||||
on_plan(max(0, src.size - st["done"]))
|
||||
progress(st["done"], "")
|
||||
if tasks.empty():
|
||||
return 0, st["skipped"], 0
|
||||
|
||||
# 2) varias conexiones subiendo archivos a la vez
|
||||
def worker():
|
||||
ww = _FtpWriter(cfg, cancel, log)
|
||||
try:
|
||||
while st["err"] is None:
|
||||
try:
|
||||
rpath, rel, size, offset = tasks.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
if offset:
|
||||
with lock:
|
||||
st["resumed"] += 1
|
||||
log("Continuando %s desde %s MB" % (rel, offset // MiB))
|
||||
local = os.path.join(src.path, *rel.split("/"))
|
||||
ww.begin(rpath, offset)
|
||||
pos = offset
|
||||
with open(local, "rb") as fh:
|
||||
fh.seek(offset)
|
||||
while True:
|
||||
if cancel.is_set() or st["err"] is not None:
|
||||
raise Cancelled()
|
||||
chunk = fh.read(2 * MiB)
|
||||
if not chunk:
|
||||
break
|
||||
ww.write(chunk)
|
||||
pos += len(chunk)
|
||||
with lock:
|
||||
st["done"] += len(chunk)
|
||||
d = st["done"]
|
||||
progress(d, rel)
|
||||
ww.end(size)
|
||||
with lock:
|
||||
st["sent"] += 1
|
||||
except Exception as e:
|
||||
with lock:
|
||||
if st["err"] is None:
|
||||
st["err"] = e
|
||||
finally:
|
||||
ww.close()
|
||||
|
||||
ths = [threading.Thread(target=worker, daemon=True) for _ in range(min(workers, tasks.qsize()))]
|
||||
for t in ths:
|
||||
t.start()
|
||||
for t in ths:
|
||||
t.join()
|
||||
if st["err"] is not None:
|
||||
if cancel.is_set() or isinstance(st["err"], Cancelled):
|
||||
raise Cancelled()
|
||||
raise st["err"]
|
||||
return st["sent"], st["skipped"], st["resumed"]
|
||||
|
||||
|
||||
def ftp_download(cfg, remote, local, cancel, progress, size=None):
|
||||
"""Baja un archivo de la PS5 al PC (continua si ya hay un .part)."""
|
||||
os.makedirs(os.path.dirname(local) or ".", exist_ok=True)
|
||||
part = local + ".part"
|
||||
retries = int(cfg.get("retries", 8))
|
||||
for attempt in range(retries + 1):
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
off = os.path.getsize(part) if os.path.isfile(part) else 0
|
||||
f = None
|
||||
try:
|
||||
f = Ftp(cfg).connect()
|
||||
with open(part, "ab") as fh:
|
||||
state = {"n": off}
|
||||
|
||||
def cb(data):
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
fh.write(data)
|
||||
state["n"] += len(data)
|
||||
progress(state["n"])
|
||||
f.retrbinary("RETR " + remote, cb, blocksize=MiB, rest=off or None)
|
||||
if size is not None and os.path.getsize(part) != size:
|
||||
raise PipeError("descarga incompleta")
|
||||
os.replace(part, local)
|
||||
return
|
||||
except Cancelled:
|
||||
raise
|
||||
except Exception as e:
|
||||
if attempt >= retries:
|
||||
raise PipeError("No pude bajar %s: %s" % (remote, e))
|
||||
time.sleep(min(30, 2 * (attempt + 1)))
|
||||
finally:
|
||||
if f is not None:
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def concat_stream(parts, offset, cfg, cancel, meter):
|
||||
"""Lee varias fuentes como si fueran un unico archivo (para unir .001 .002 ...)."""
|
||||
base = 0
|
||||
@@ -516,9 +1036,39 @@ def concat_stream(parts, offset, cfg, cancel, meter):
|
||||
# FTP de la PS5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clean_host(text):
|
||||
"""Deja solo la IP/nombre de la PS5 aunque se escriba con espacios, ftp://, puerto o
|
||||
comas: ' ftp://192,168,1,50:2121/ ' -> ('192.168.1.50', 2121)."""
|
||||
t = re.sub(r"[\s\u200b\u00a0\ufeff]+", "", str(text or ""))
|
||||
t = re.sub(r"^[a-z][a-z0-9+.-]*://", "", t, flags=re.I)
|
||||
t = t.split("/", 1)[0].split("@")[-1]
|
||||
port = None
|
||||
m = re.match(r"^(.*?):(\d{1,5})$", t)
|
||||
if m and m.group(1).count(":") == 0:
|
||||
t, port = m.group(1), int(m.group(2))
|
||||
if re.fullmatch(r"[\d,.]+", t):
|
||||
t = t.replace(",", ".")
|
||||
return t.strip(".").strip("[]"), port
|
||||
|
||||
|
||||
def raw_mode(f):
|
||||
"""ftpsrv (ps5-payload-dev) descifra al vuelo los SELF (eboot.bin, .prx, .sprx): al bajarlos
|
||||
llega el ELF de dentro, con otro tamano, y los listados tambien dan ese tamano. Su comando
|
||||
SELF lo activa/desactiva en cada conexion: lo dejamos apagado para ver los archivos tal
|
||||
cual estan. Los servidores que no lo conocen responden con error y no pasa nada.
|
||||
(Gracias a RichyMX por encontrarlo.)"""
|
||||
try:
|
||||
r = f.sendcmd("SELF")
|
||||
if "enabled" in r.lower():
|
||||
r = f.sendcmd("SELF")
|
||||
return "disabled" in r.lower()
|
||||
except ftplib.all_errors:
|
||||
return False
|
||||
|
||||
|
||||
class Ftp:
|
||||
def __init__(self, cfg):
|
||||
self.host = cfg["ps5_ip"]
|
||||
self.host = clean_host(cfg["ps5_ip"])[0]
|
||||
self.port = int(cfg.get("ftp_port", 2121))
|
||||
self.user = cfg.get("ftp_user") or "anonymous"
|
||||
self.pw = cfg.get("ftp_pass") or ""
|
||||
@@ -528,10 +1078,15 @@ class Ftp:
|
||||
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)
|
||||
try:
|
||||
f.connect(self.host, self.port, timeout=30)
|
||||
except socket.gaierror:
|
||||
raise PipeError("La IP de la PS5 no es valida (%s): en Ajustes escribe solo los numeros, "
|
||||
"por ejemplo 192.168.1.50, sin ftp:// ni puerto" % self.host)
|
||||
f.login(self.user, self.pw)
|
||||
f.set_pasv(True)
|
||||
f.voidcmd("TYPE I")
|
||||
raw_mode(f)
|
||||
try:
|
||||
f.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
except Exception:
|
||||
@@ -679,7 +1234,7 @@ def upload(cfg, remote_final, total, make_iter, progress, cancel, log, resume=Tr
|
||||
|
||||
class BFpilot:
|
||||
def __init__(self, cfg):
|
||||
self.base = "http://%s:%s" % (cfg["ps5_ip"], int(cfg.get("bf_port", 5905)))
|
||||
self.base = "http://%s:%s" % (clean_host(cfg["ps5_ip"])[0], int(cfg.get("bf_port", 5905)))
|
||||
self.cfg = cfg
|
||||
|
||||
def _req(self, method, path, params=None, form=None, timeout=20):
|
||||
@@ -802,11 +1357,16 @@ class BFpilot:
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def wait_job(self, cancel, timeout=3600):
|
||||
def wait_job(self, cancel, timeout=3600, on_tick=None):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise Cancelled()
|
||||
if on_tick is not None:
|
||||
try:
|
||||
on_tick()
|
||||
except Exception:
|
||||
pass
|
||||
j = self._req("GET", "/api/fs/job/status")
|
||||
if not j.get("busy"):
|
||||
if j.get("error"):
|
||||
@@ -815,7 +1375,7 @@ class BFpilot:
|
||||
time.sleep(1)
|
||||
raise PipeError("BFpilot tarda demasiado")
|
||||
|
||||
def delete(self, path, cancel=None):
|
||||
def delete(self, path, cancel=None, on_tick=None):
|
||||
for _ in range(60):
|
||||
try:
|
||||
self._req("POST", "/api/fs/delete", form={"path": path, "recursive": "1"})
|
||||
@@ -827,12 +1387,12 @@ class BFpilot:
|
||||
if "No such file" in str(e) or "not found" in str(e).lower():
|
||||
return
|
||||
raise
|
||||
self.wait_job(cancel)
|
||||
self.wait_job(cancel, timeout=4 * 3600, on_tick=on_tick)
|
||||
|
||||
def du(self, path):
|
||||
def du(self, path, timeout=20):
|
||||
"""Tamano total de una carpeta (o archivo), o None."""
|
||||
try:
|
||||
return int(self._req("GET", "/api/fs/du", {"path": path}).get("size") or 0)
|
||||
return int(self._req("GET", "/api/fs/du", {"path": path}, timeout=timeout).get("size") or 0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -1047,18 +1607,18 @@ class _FtpWriter:
|
||||
else:
|
||||
c = self.f.transfercmd("STOR " + path)
|
||||
try:
|
||||
c.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2 * MiB)
|
||||
c.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * MiB)
|
||||
except Exception:
|
||||
pass
|
||||
return c
|
||||
|
||||
def begin(self, path):
|
||||
def begin(self, path, offset=0):
|
||||
self.path = path
|
||||
self.sent = 0
|
||||
self.sent = offset
|
||||
self.tail = deque()
|
||||
self.tail_len = 0
|
||||
self.mkdir(path.rsplit("/", 1)[0] or "/")
|
||||
self.conn = self._retry(lambda: self._open_data(path, 0))
|
||||
self.conn = self._retry(lambda: self._open_data(path, offset))
|
||||
|
||||
def _remember(self, data):
|
||||
self.tail.append(data)
|
||||
@@ -1231,3 +1791,76 @@ def stream_extract_7z(exe, archive, entries, password, remote_root, cfg, cancel,
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _CountReader:
|
||||
"""Envuelve un archivo y cuenta lo leido (para el progreso del .tar.gz)."""
|
||||
def __init__(self, f):
|
||||
self.f, self.n = f, 0
|
||||
|
||||
def read(self, k=-1):
|
||||
d = self.f.read(k)
|
||||
self.n += len(d)
|
||||
return d
|
||||
|
||||
|
||||
def stream_extract_tar(path, remote_root, cfg, cancel, log, progress, on_file=None, skip=0):
|
||||
"""Descomprime un .tar / .tar.gz / .tar.bz2 / .tar.xz del PC sobre la marcha y sube cada
|
||||
archivo a remote_root. progress(bytes_del_comprimido_leidos, total_comprimido, ruta).
|
||||
skip: miembros ya subidos antes (se leen y se descartan).
|
||||
Devuelve (archivos, bytes, {ruta de sce_sys/param.json: contenido})."""
|
||||
import tarfile
|
||||
total = os.path.getsize(path)
|
||||
nfiles = nbytes = 0
|
||||
params = {}
|
||||
w = _FtpWriter(cfg, cancel, log)
|
||||
try:
|
||||
with open(path, "rb") as raw:
|
||||
cr = _CountReader(raw)
|
||||
with tarfile.open(fileobj=cr, mode="r|*") as tf:
|
||||
for i, m in enumerate(tf):
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
parts = [x for x in m.name.replace("\\", "/").split("/") if x not in ("", ".")]
|
||||
if not parts or ".." in parts:
|
||||
raise PipeError("Ruta peligrosa dentro del comprimido: %s" % m.name)
|
||||
remote = remote_root + "/" + "/".join(parts)
|
||||
rel = "/".join(parts)
|
||||
if m.isfile() and rel.lower().endswith("sce_sys/param.json") and m.size < MiB:
|
||||
params[rel] = tf.extractfile(m).read()
|
||||
if i < skip:
|
||||
continue
|
||||
w.begin(remote)
|
||||
w.write(params[rel])
|
||||
w.end(m.size)
|
||||
nfiles += 1
|
||||
nbytes += m.size
|
||||
if on_file:
|
||||
on_file(i + 1)
|
||||
continue
|
||||
if i < skip:
|
||||
continue
|
||||
if m.isdir():
|
||||
w.mkdir(remote)
|
||||
elif m.isfile():
|
||||
fh = tf.extractfile(m)
|
||||
w.begin(remote)
|
||||
while True:
|
||||
data = fh.read(4 * MiB)
|
||||
if not data:
|
||||
break
|
||||
w.write(data)
|
||||
progress(cr.n, total, m.name)
|
||||
w.end(m.size)
|
||||
nfiles += 1
|
||||
nbytes += m.size
|
||||
else:
|
||||
log("Me salto %s (enlace o archivo especial)" % m.name)
|
||||
if on_file:
|
||||
on_file(i + 1)
|
||||
progress(cr.n, total, m.name)
|
||||
except tarfile.TarError as e:
|
||||
raise PipeError("El .tar esta danado o no es un tar: %s" % e)
|
||||
finally:
|
||||
w.close()
|
||||
return nfiles, nbytes, params
|
||||
@@ -1,3 +1,4 @@
|
||||
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
|
||||
// PS5 Directo - captura de enlaces.
|
||||
// Cuando el navegador empieza a descargar un archivo comprimido o paquete (.rar, .7z, .zip, .pkg...),
|
||||
// manda el enlace FINAL (despues de redirecciones) a PS5 Directo y cancela la descarga.
|
||||
@@ -43,11 +44,24 @@ async function handle(item) {
|
||||
chrome.action.setBadgeText({ text: "off" });
|
||||
return;
|
||||
}
|
||||
// Como los gestores de descargas: pasamos las cookies de ese alojamiento (sesion, cuenta
|
||||
// premium), el navegador y la pagina de origen, para que el servidor nos trate igual.
|
||||
// Solo van a PS5 Directo en este PC (127.0.0.1), a ningun otro sitio.
|
||||
const cookies = {};
|
||||
for (const u of [url, item.url]) {
|
||||
try {
|
||||
const host = new URL(u).host.toLowerCase();
|
||||
if (host in cookies) continue;
|
||||
const list = await chrome.cookies.getAll({ url: u });
|
||||
cookies[host] = list.map(c => `${c.name}=${c.value}`).join("; ");
|
||||
} catch (e) { /* sin permiso o enlace raro: sin cookies */ }
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${app.port}/api/ext/capture`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, original: item.url, name })
|
||||
body: JSON.stringify({ url, original: item.url, name, cookies,
|
||||
ua: navigator.userAgent, referrer: item.referrer || "" })
|
||||
});
|
||||
if (!r.ok) return;
|
||||
} catch (e) { return; }
|
||||
@@ -58,8 +72,8 @@ async function handle(item) {
|
||||
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."
|
||||
type: "basic", iconUrl: "icon128.png", title: EN ? "Sent to PS5 Directo" : "Enviado a PS5 Directo",
|
||||
message: (name || (EN ? "Link" : "Enlace")) + (EN ? " — not downloaded to the PC." : " — no se descarga en el PC.")
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "PS5 Directo - Capturar enlaces",
|
||||
"version": "1.0.0",
|
||||
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace a PS5 Directo en vez de bajarlo al PC.",
|
||||
"permissions": ["downloads", "storage", "notifications"],
|
||||
"host_permissions": ["http://127.0.0.1/*"],
|
||||
"background": { "service_worker": "bg.js" },
|
||||
"action": { "default_popup": "popup.html", "default_title": "PS5 Directo", "default_icon": { "16": "icon16.png", "32": "icon32.png" } },
|
||||
"icons": { "16": "icon16.png", "48": "icon48.png", "128": "icon128.png" }
|
||||
}
|
||||
"version": "1.1.0",
|
||||
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace (con la sesion del navegador) a PS5 Directo en vez de bajarlo al PC.",
|
||||
"permissions": [
|
||||
"downloads",
|
||||
"storage",
|
||||
"notifications",
|
||||
"cookies"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "bg.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "PS5 Directo",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"32": "icon32.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ button{background:#1f2937;color:#e6edf3;border:1px solid #374151;border-radius:6
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>PS5 Directo</h1>
|
||||
<div class="row"><label for="en">Capturar enlaces</label><input type="checkbox" id="en"></div>
|
||||
<div class="row"><label for="en" id="lblEn">Capturar enlaces</label><input type="checkbox" id="en"></div>
|
||||
<div class="st" id="st">Comprobando…</div>
|
||||
<div class="st" id="cnt"></div>
|
||||
<div class="row"><span></span><button id="reset">Poner contador a 0</button></div>
|
||||
<small>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.</small>
|
||||
<small id="help">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.</small>
|
||||
<script src="popup.js"></script>
|
||||
</body></html>
|
||||
@@ -1,3 +1,19 @@
|
||||
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
|
||||
const T = EN ? {
|
||||
capture: "Capture links", checking: "Checking…", reset: "Reset counter",
|
||||
help: "Pass the captcha and click Download as usual: if it's a .rar, .7z, .zip, .pkg… and PS5 Directo is open, the link goes to its link box and the browser downloads nothing. If PS5 Directo is closed, the download goes ahead normally.",
|
||||
sent: "Sent", last: "last", connected: "● Connected", to: "to PS5 Directo", closed: "● PS5 Directo is not open", normal: "(downloads will work normally)"
|
||||
} : {
|
||||
capture: "Capturar enlaces", checking: "Comprobando…", reset: "Poner contador a 0",
|
||||
help: null, sent: "Enviados", last: "último", connected: "● Conectado", to: "a PS5 Directo", closed: "● PS5 Directo no está abierto", normal: "(las descargas seguirán normales)"
|
||||
};
|
||||
if (EN) {
|
||||
document.documentElement.lang = "en";
|
||||
document.getElementById("lblEn").textContent = T.capture;
|
||||
document.getElementById("st").textContent = T.checking;
|
||||
document.getElementById("reset").textContent = T.reset;
|
||||
document.getElementById("help").textContent = T.help;
|
||||
}
|
||||
async function findApp() {
|
||||
for (const port of [8777, 8778, 8779]) {
|
||||
try {
|
||||
@@ -13,7 +29,7 @@ async function findApp() {
|
||||
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 + ")" : ""}` : "";
|
||||
cfg.captured ? `${T.sent}: ${cfg.captured}${cfg.last ? " (" + T.last + ": " + cfg.last + ")" : ""}` : "";
|
||||
document.getElementById("reset").onclick = async () => {
|
||||
await chrome.storage.local.set({ captured: 0, last: "" });
|
||||
chrome.action.setBadgeText({ text: "" });
|
||||
@@ -21,6 +37,6 @@ async function findApp() {
|
||||
};
|
||||
const app = await findApp();
|
||||
const st = document.getElementById("st");
|
||||
if (app) { st.innerHTML = `<span class="ok">● Conectado</span> a PS5 Directo v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
|
||||
else st.innerHTML = '<span class="bad">● PS5 Directo no está abierto</span> (las descargas seguirán normales)';
|
||||
if (app) { st.innerHTML = `<span class="ok">${T.connected}</span> ${T.to} v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
|
||||
else st.innerHTML = `<span class="bad">${T.closed}</span> ${T.normal}`;
|
||||
})();
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3a7bff"/>
|
||||
<stop offset=".55" stop-color="#4b4fe6"/>
|
||||
<stop offset="1" stop-color="#7b3fe4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx=".3" cy=".2" r=".9">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".35"/>
|
||||
<stop offset=".5" stop-color="#fff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="arrow" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe6ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="58" fill="url(#bg)"/>
|
||||
<rect x="8" y="8" width="240" height="240" rx="58" fill="url(#glow)"/>
|
||||
<rect x="9.5" y="9.5" width="237" height="237" rx="56.5" fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="3"/>
|
||||
<!-- estelas de velocidad -->
|
||||
<g stroke="#fff" stroke-linecap="round" stroke-opacity=".45" stroke-width="9">
|
||||
<line x1="62" y1="58" x2="62" y2="92"/>
|
||||
<line x1="194" y1="58" x2="194" y2="92"/>
|
||||
</g>
|
||||
<!-- flecha -->
|
||||
<path d="M128 44 c9 0 15 6 15 15 v58 h22 c11 0 16 13 8 21 l-35 37 c-6 6-14 6-20 0 l-35-37 c-8-8-3-21 8-21 h22 v-58 c0-9 6-15 15-15z" fill="url(#arrow)"/>
|
||||
<!-- bandeja / consola -->
|
||||
<path d="M52 168 v18 c0 14 10 24 24 24 h104 c14 0 24-10 24-24 v-18" fill="none" stroke="#fff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="176" cy="190" r="5.5" fill="#7dffb2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "PS5 Directo - Capturar enlaces",
|
||||
"version": "1.1.0",
|
||||
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace (con la sesion del navegador) a PS5 Directo en vez de bajarlo al PC.",
|
||||
"permissions": [
|
||||
"downloads",
|
||||
"storage",
|
||||
"notifications",
|
||||
"cookies"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "bg.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "PS5 Directo",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"32": "icon32.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="es"><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font:14px system-ui,sans-serif;background:#0d1117;color:#e6edf3;margin:0;padding:14px;width:280px}
|
||||
h1{font-size:15px;margin:0 0 10px}
|
||||
.row{display:flex;align-items:center;justify-content:space-between;margin:8px 0}
|
||||
.st{font-size:13px;opacity:.85}
|
||||
.ok{color:#4ade80}.bad{color:#f87171}
|
||||
small{opacity:.6;display:block;margin-top:10px;line-height:1.4}
|
||||
button{background:#1f2937;color:#e6edf3;border:1px solid #374151;border-radius:6px;padding:4px 10px;cursor:pointer}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>PS5 Directo</h1>
|
||||
<div class="row"><label for="en" id="lblEn">Capturar enlaces</label><input type="checkbox" id="en"></div>
|
||||
<div class="st" id="st">Comprobando…</div>
|
||||
<div class="st" id="cnt"></div>
|
||||
<div class="row"><span></span><button id="reset">Poner contador a 0</button></div>
|
||||
<small id="help">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.</small>
|
||||
<script src="popup.js"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,42 @@
|
||||
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
|
||||
const T = EN ? {
|
||||
capture: "Capture links", checking: "Checking…", reset: "Reset counter",
|
||||
help: "Pass the captcha and click Download as usual: if it's a .rar, .7z, .zip, .pkg… and PS5 Directo is open, the link goes to its link box and the browser downloads nothing. If PS5 Directo is closed, the download goes ahead normally.",
|
||||
sent: "Sent", last: "last", connected: "● Connected", to: "to PS5 Directo", closed: "● PS5 Directo is not open", normal: "(downloads will work normally)"
|
||||
} : {
|
||||
capture: "Capturar enlaces", checking: "Comprobando…", reset: "Poner contador a 0",
|
||||
help: null, sent: "Enviados", last: "último", connected: "● Conectado", to: "a PS5 Directo", closed: "● PS5 Directo no está abierto", normal: "(las descargas seguirán normales)"
|
||||
};
|
||||
if (EN) {
|
||||
document.documentElement.lang = "en";
|
||||
document.getElementById("lblEn").textContent = T.capture;
|
||||
document.getElementById("st").textContent = T.checking;
|
||||
document.getElementById("reset").textContent = T.reset;
|
||||
document.getElementById("help").textContent = T.help;
|
||||
}
|
||||
async function findApp() {
|
||||
for (const port of [8777, 8778, 8779]) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" });
|
||||
if (r.ok) { const j = await r.json(); if (j.app === "PS5 Directo") return j; }
|
||||
} catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
(async () => {
|
||||
const cfg = await chrome.storage.local.get({ enabled: true, captured: 0, last: "" });
|
||||
const en = document.getElementById("en");
|
||||
en.checked = cfg.enabled;
|
||||
en.onchange = () => chrome.storage.local.set({ enabled: en.checked });
|
||||
document.getElementById("cnt").textContent =
|
||||
cfg.captured ? `${T.sent}: ${cfg.captured}${cfg.last ? " (" + T.last + ": " + cfg.last + ")" : ""}` : "";
|
||||
document.getElementById("reset").onclick = async () => {
|
||||
await chrome.storage.local.set({ captured: 0, last: "" });
|
||||
chrome.action.setBadgeText({ text: "" });
|
||||
document.getElementById("cnt").textContent = "";
|
||||
};
|
||||
const app = await findApp();
|
||||
const st = document.getElementById("st");
|
||||
if (app) { st.innerHTML = `<span class="ok">${T.connected}</span> ${T.to} v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
|
||||
else st.innerHTML = `<span class="bad">${T.closed}</span> ${T.normal}`;
|
||||
})();
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,48 @@
|
||||
# Publica una version: al subir una etiqueta vX.Y.Z crea la release de GitHub con dos .zip:
|
||||
# - PS5Directo-X.Y.Z.zip la app (la que usa "Actualizar" dentro del programa)
|
||||
# - PS5Directo-X.Y.Z-portable-windows.zip la app + Python incluido: descomprimir y abrir, sin instalar nada
|
||||
name: Publicar version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Version de app.py
|
||||
id: v
|
||||
run: echo "ver=$(grep -oP 'VERSION = "\K[^"]+' app.py)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: La etiqueta tiene que coincidir con la version
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: test "${GITHUB_REF_NAME#v}" = "${{ steps.v.outputs.ver }}"
|
||||
|
||||
- name: Paquete normal
|
||||
run: |
|
||||
mkdir -p dist/PS5Directo
|
||||
git archive HEAD | tar -x -C dist/PS5Directo
|
||||
rm -rf dist/PS5Directo/.github
|
||||
(cd dist && zip -qr "PS5Directo-${{ steps.v.outputs.ver }}.zip" PS5Directo)
|
||||
|
||||
- name: Paquete portable (con Python para Windows)
|
||||
run: |
|
||||
PY=3.12.10
|
||||
curl -fsSL -o py.zip "https://www.python.org/ftp/python/$PY/python-$PY-embed-amd64.zip"
|
||||
mkdir -p dist/PS5Directo/python
|
||||
unzip -q py.zip -d dist/PS5Directo/python
|
||||
# que el Python incluido vea la carpeta del programa
|
||||
for f in dist/PS5Directo/python/python*._pth; do printf '..\n' >> "$f"; done
|
||||
(cd dist && zip -qr "PS5Directo-${{ steps.v.outputs.ver }}-portable-windows.zip" PS5Directo)
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: dist/*.zip
|
||||
@@ -20,7 +20,7 @@ header .sub{color:var(--muted);font-size:13px}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);font-size:12.5px;color:var(--muted);white-space:nowrap}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
||||
.dot.ok{background:var(--ok)}.dot.err{background:var(--err)}.dot.warn{background:var(--warn)}
|
||||
main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;gap:18px}
|
||||
main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;grid-template-columns:minmax(0,1fr);gap:18px}
|
||||
.card{background:rgba(22,27,34,.86);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:var(--radius);padding:18px}
|
||||
.card h2{font-size:15px;margin:0 0 12px;font-weight:600}
|
||||
textarea,input,select{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit}
|
||||
@@ -64,6 +64,82 @@ dialog .head h2{margin:0;font-size:16px}
|
||||
dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
.check{display:flex;gap:9px;align-items:center;margin-top:12px;font-size:14px;color:var(--text)}
|
||||
.check input{width:auto}
|
||||
/* --- pestanas --- */
|
||||
.tabs{display:flex;gap:4px;max-width:1060px;margin:14px auto 0;padding:0 20px}
|
||||
.tabs button{background:transparent;border:1px solid transparent;border-radius:9px;padding:8px 14px;color:var(--muted);font-weight:600}
|
||||
.tabs button:hover{color:var(--text);border-color:var(--line)}
|
||||
.tabs button.on{color:var(--text);background:var(--panel2);border-color:var(--line)}
|
||||
.tabs{overflow-x:auto;scrollbar-width:none}.tabs button{flex:none}
|
||||
@media(max-width:600px){.tabs{padding:0 12px;gap:2px}.tabs button{padding:7px 9px;font-size:13.5px}}
|
||||
.pickrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||
/* --- explorador --- */
|
||||
.fm-head{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||
.fm-head h2{margin:0}
|
||||
.store{min-width:220px;text-align:right;font-size:13px;color:var(--muted)}
|
||||
.store b{color:var(--text)}
|
||||
.store .bar{margin-top:6px;height:6px}
|
||||
@media(max-width:600px){.store{min-width:0;flex:1 1 100%;text-align:left}#tabFiles .fmtable{display:block;overflow-x:auto}}
|
||||
.fm-bar{display:flex;gap:6px;align-items:center;margin-top:14px;flex-wrap:wrap}
|
||||
.fm-bar input,.fm-bar select{width:auto}
|
||||
.fm-bar #fmPath{flex:1;min-width:220px;font-family:Consolas,"Cascadia Mono",monospace;font-size:13px}
|
||||
.fm-bar #fmSearch{width:170px}
|
||||
.fm-bar #fmPlaces{width:150px}
|
||||
.iconbtn{padding:6px 10px;min-width:34px}
|
||||
.fm-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;align-items:center}
|
||||
.fm-actions .sep{width:1px;height:22px;background:var(--line);margin:0 4px}
|
||||
.fm-actions button[disabled]{opacity:.4;cursor:default}
|
||||
.clip,.fmop{margin-top:10px;padding:9px 12px;border:1px solid #2f5490;background:rgba(61,139,253,.08);border-radius:9px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:13.5px}
|
||||
.fmop .bar{flex:1;min-width:160px;margin:0}
|
||||
.fmtable{width:100%;border-collapse:collapse;margin-top:12px;font-size:13.5px}
|
||||
.fmtable th{color:var(--muted);font-weight:600;font-size:12px;text-align:left;padding:8px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;white-space:nowrap}
|
||||
.fmtable td{padding:7px 8px;border-bottom:1px solid rgba(42,50,61,.6);vertical-align:middle}
|
||||
.fmtable tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.fmtable tr.sel td{background:rgba(61,139,253,.12)}
|
||||
.fmtable td.num,.fmtable th.num{text-align:right;white-space:nowrap}
|
||||
.fmtable td.mono{font-family:Consolas,"Cascadia Mono",monospace;color:var(--muted);font-size:12.5px}
|
||||
.fmtable input[type=checkbox]{width:auto}
|
||||
.fname{display:flex;align-items:center;gap:8px;cursor:pointer}
|
||||
.fname svg{flex:none;opacity:.85}
|
||||
.fname.dir span{color:#cfe0ff}
|
||||
.linkbtn{background:none;border:none;padding:0;color:var(--accent);cursor:pointer;font-size:12.5px}
|
||||
.rowact{display:flex;gap:4px;justify-content:flex-end;opacity:.5}
|
||||
tr:hover .rowact{opacity:1}
|
||||
.rowact button{padding:3px 8px;font-size:12px}
|
||||
.news-v{margin:14px 0 6px;font-size:14px;color:var(--accent)}
|
||||
.news-list{margin:0;padding-left:20px;line-height:1.5}.news-list li{margin:6px 0}
|
||||
.sp-grid{display:grid;grid-template-columns:280px 1fr;gap:22px;align-items:center;margin-top:14px}
|
||||
@media(max-width:760px){.sp-grid{grid-template-columns:1fr}.sp-donut{margin:0 auto}}
|
||||
.sp-donut{position:relative;width:280px;height:280px}
|
||||
.sp-donut svg{display:block}
|
||||
.sp-donut path{cursor:pointer;transition:opacity .15s}
|
||||
.sp-donut.hl path:not(.on){opacity:.35}
|
||||
.sp-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;pointer-events:none;padding:0 70px}
|
||||
.sp-center b{font-size:22px}.sp-center span{color:var(--muted);font-size:12.5px}
|
||||
.sp-legend{display:flex;flex-direction:column;gap:5px;font-size:13.5px}
|
||||
.sp-legend .row{display:flex;align-items:center;gap:9px;padding:4px 6px;border-radius:7px;cursor:pointer}
|
||||
.sp-legend .row:hover,.sp-legend .row.on{background:rgba(255,255,255,.05)}
|
||||
.sp-legend .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.sp-legend .sz{color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.sw{width:12px;height:12px;border-radius:3px;flex:none;display:inline-block}
|
||||
.sw.free{background:transparent;border:1.5px dashed #5b6573}
|
||||
.sp-note{font-size:12px;color:var(--muted);margin-top:2px}
|
||||
.sp-note.dup,.sp-note.bad{color:var(--warn)}
|
||||
.sp-pct{height:5px;background:var(--bg);border-radius:4px;overflow:hidden;margin-top:4px;max-width:180px}
|
||||
.sp-pct>i{display:block;height:100%;border-radius:4px}
|
||||
.sp-tip{position:fixed;z-index:50;pointer-events:none;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12.5px;box-shadow:0 6px 20px rgba(0,0,0,.4);max-width:320px}
|
||||
.sp-tools{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.sp-tools select{width:auto}
|
||||
#spBody .mono{word-break:break-all}
|
||||
.sp-grid>*{min-width:0}
|
||||
#spListCard table{table-layout:fixed}
|
||||
#spListCard th:nth-child(1){width:28px}#spListCard th:nth-child(3){width:90px}#spListCard th:nth-child(4){width:150px}
|
||||
@media(max-width:600px){#spListCard th:nth-child(4){width:76px}#spListCard th:nth-child(3){width:72px}}
|
||||
#spBody td{overflow-wrap:anywhere}
|
||||
@media(max-width:600px){#spBody td:last-child button{display:block;width:100%;margin:3px 0}#spBody td:last-child{white-space:normal}}
|
||||
.acttable{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:10px}
|
||||
.acttable th{color:var(--muted);font-weight:600;font-size:12px;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
|
||||
.acttable td{padding:8px;border-bottom:1px solid rgba(42,50,61,.6);vertical-align:top}
|
||||
@media(max-width:720px){.fmtable .hide-s{display:none}}
|
||||
/* --- guia de bienvenida --- */
|
||||
.tour-spot{position:fixed;z-index:50;border-radius:12px;box-shadow:0 0 0 9999px rgba(5,8,14,.72),0 0 0 2px var(--accent),0 0 24px rgba(61,139,253,.55);transition:all .25s ease;pointer-events:none}
|
||||
.tour-spot.none{box-shadow:0 0 0 9999px rgba(5,8,14,.72);width:0!important;height:0!important}
|
||||
@@ -92,26 +168,38 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
<header>
|
||||
<svg width="40" height="40" style="flex:none;filter:drop-shadow(0 4px 12px rgba(75,79,230,.45))" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <defs> <linearGradient id="lg_bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0" stop-color="#3a7bff"/> <stop offset=".55" stop-color="#4b4fe6"/> <stop offset="1" stop-color="#7b3fe4"/> </linearGradient> <radialGradient id="lg_glow" cx=".3" cy=".2" r=".9"> <stop offset="0" stop-color="#fff" stop-opacity=".35"/> <stop offset=".5" stop-color="#fff" stop-opacity="0"/> </radialGradient> <linearGradient id="lg_arrow" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#ffffff"/> <stop offset="1" stop-color="#dfe6ff"/> </linearGradient> </defs> <rect x="8" y="8" width="240" height="240" rx="58" fill="url(#lg_bg)"/> <rect x="8" y="8" width="240" height="240" rx="58" fill="url(#lg_glow)"/> <rect x="9.5" y="9.5" width="237" height="237" rx="56.5" fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="3"/> <g stroke="#fff" stroke-linecap="round" stroke-opacity=".45" stroke-width="9"> <line x1="62" y1="58" x2="62" y2="92"/> <line x1="194" y1="58" x2="194" y2="92"/> </g> <path d="M128 44 c9 0 15 6 15 15 v58 h22 c11 0 16 13 8 21 l-35 37 c-6 6-14 6-20 0 l-35-37 c-8-8-3-21 8-21 h22 v-58 c0-9 6-15 15-15z" fill="url(#lg_arrow)"/> <path d="M52 168 v18 c0 14 10 24 24 24 h104 c14 0 24-10 24-24 v-18" fill="none" stroke="#fff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/> <circle cx="176" cy="190" r="5.5" fill="#7dffb2"/> </svg>
|
||||
<div>
|
||||
<h1>PS5 Directo <span id="ver" style="font-size:12px;font-weight:400;opacity:.55"></span></h1>
|
||||
<h1>PS5 Directo <span id="ver" data-noi18n style="font-size:12px;font-weight:400;opacity:.55"></span></h1>
|
||||
<div class="sub">Envía archivos a tu consola por la red local, sin pasar por el disco del PC</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill" id="pFtp"><span class="dot"></span>FTP</span>
|
||||
<span class="pill" id="pBf"><span class="dot"></span>BFpilot</span>
|
||||
<span class="pill" id="pFree" hidden></span>
|
||||
<button class="small" id="btnLang" data-noi18n title="Idioma / Language"></button>
|
||||
<button class="small" id="btnTour" title="Guía rápida: para qué es cada cosa">Guía</button>
|
||||
<button class="small" id="btnExtHead" title="Capturar enlaces desde el navegador sin copiar y pegar">Extensión</button>
|
||||
<button class="small" id="btnUpdHead" title="Buscar o instalar una versión nueva">Actualizar</button>
|
||||
<button class="small" id="btnSettings">Ajustes</button>
|
||||
<button class="small" id="btnQuit" title="Cierra PS5 Directo del todo">Salir</button>
|
||||
</header>
|
||||
<nav class="tabs" id="tabs">
|
||||
<button data-tab="Transfers" class="on">Transferencias</button>
|
||||
<button data-tab="Files">Archivos de la consola</button>
|
||||
<button data-tab="Space">Espacio</button>
|
||||
<button data-tab="Activity">Actividad</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<main id="tabTransfers">
|
||||
<section class="card">
|
||||
<h2>Nueva transferencia</h2>
|
||||
<label for="lines">Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…</label>
|
||||
<textarea id="lines" placeholder="https://servidor/archivo.part1.rar https://servidor/archivo.part2.rar También vale la ruta de un archivo de tu PC, por ejemplo: D:\Descargas\archivo.rar"></textarea>
|
||||
<textarea id="lines" placeholder="https://servidor/archivo.part1.rar https://servidor/archivo.part2.rar También vale la ruta de un archivo o carpeta de tu PC (D:\Descargas\archivo.rar) o un enlace ftp:// de tu NAS"></textarea>
|
||||
<div class="drophint" id="drophint">Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el <b>icono de PS5 Directo</b>, o clic derecho → <b>Enviar a → PS5 Directo</b>.</div>
|
||||
<div class="pickrow">
|
||||
<button class="small" id="btnPickFiles">Elegir archivos…</button>
|
||||
<button class="small" id="btnPickFolder">Elegir carpeta…</button>
|
||||
<span class="hint">Una carpeta se manda tal cual (si tiene sce_sys, como aplicación) y salta lo que ya esté igual en la consola.</span>
|
||||
</div>
|
||||
<div class="row tri">
|
||||
<div>
|
||||
<label for="pw">Contraseña del comprimido (si la tiene)</label>
|
||||
@@ -146,6 +234,85 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="tabFiles" hidden>
|
||||
<section class="card">
|
||||
<div class="fm-head">
|
||||
<h2>Archivos de la consola</h2>
|
||||
<div class="spacer"></div>
|
||||
<div class="store" id="fmStore"></div>
|
||||
</div>
|
||||
<div class="fm-bar">
|
||||
<button class="small iconbtn" id="fmBack" title="Atrás">←</button>
|
||||
<button class="small iconbtn" id="fmFwd" title="Adelante">→</button>
|
||||
<button class="small iconbtn" id="fmUp" title="Subir un nivel">↑</button>
|
||||
<input id="fmPath" value="/data/homebrew" spellcheck="false">
|
||||
<select id="fmPlaces" title="Ir a"><option value="">Ir a…</option></select>
|
||||
<input id="fmSearch" placeholder="Buscar aquí…">
|
||||
<button class="small iconbtn" id="fmRefresh" title="Actualizar">⟳</button>
|
||||
</div>
|
||||
<div class="fm-actions">
|
||||
<button class="small" id="fmMkdir">Nueva carpeta</button>
|
||||
<button class="small" id="fmUpFiles">Subir archivos aquí</button>
|
||||
<button class="small" id="fmUpFolder">Subir carpeta aquí</button>
|
||||
<span class="sep"></span>
|
||||
<button class="small" id="fmCopy" disabled>Copiar</button>
|
||||
<button class="small" id="fmCut" disabled>Mover</button>
|
||||
<button class="small" id="fmRename" disabled>Renombrar</button>
|
||||
<button class="small" id="fmDownload" disabled>Descargar al PC</button>
|
||||
<button class="small" id="fmSize" disabled>Calcular tamaño</button>
|
||||
<button class="small" id="fmPerms" disabled>Permisos 777</button>
|
||||
<button class="small danger" id="fmDelete" disabled>Borrar</button>
|
||||
</div>
|
||||
<div class="clip" id="fmClip" hidden></div>
|
||||
<div class="fmop" id="fmOp" hidden></div>
|
||||
<table class="fmtable">
|
||||
<thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="fmAll"></th>
|
||||
<th data-sort="name">Nombre</th>
|
||||
<th data-sort="size" class="num">Tamaño</th>
|
||||
<th data-sort="mtime" class="hide-s">Modificado</th>
|
||||
<th class="hide-s">Permisos</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody id="fmBody"></tbody>
|
||||
</table>
|
||||
<p class="hint" id="fmHint" style="margin-top:10px"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="tabSpace" hidden>
|
||||
<section class="card">
|
||||
<div class="fm-head"><h2>Espacio</h2><div class="spacer"></div>
|
||||
<div class="sp-tools"><select id="spRoot"><option value="/data">Almacenamiento interno</option></select>
|
||||
<button class="primary small" id="spScan">Analizar</button></div></div>
|
||||
<p class="hint">Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.</p>
|
||||
<div id="spProg" hidden><div class="phase" id="spPhase"></div><div class="bar"><i id="spBar"></i></div>
|
||||
<button class="small" id="spCancel" style="margin-top:8px">Cancelar</button></div>
|
||||
<div id="spOut"></div>
|
||||
</section>
|
||||
<section class="card" id="spListCard" hidden>
|
||||
<div class="fm-head"><h2>Todo lo que ocupa sitio</h2><div class="spacer"></div>
|
||||
<button class="small" id="spDel" disabled>Borrar seleccionados</button></div>
|
||||
<table class="fmtable"><thead><tr><th style="width:28px"></th><th>Nombre</th><th class="num">Tamaño</th><th></th></tr></thead>
|
||||
<tbody id="spBody"></tbody></table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="tabActivity" hidden>
|
||||
<section class="card">
|
||||
<div class="fm-head"><h2>Actividad</h2><div class="spacer"></div><button class="small" id="actClear">Borrar historial</button></div>
|
||||
<p class="hint">Historial de transferencias terminadas o con error (las últimas 500).</p>
|
||||
<table class="acttable"><thead><tr><th>Fecha</th><th>Nombre</th><th>Resultado</th><th class="num">Tamaño</th><th>Duración</th><th>Destino</th></tr></thead>
|
||||
<tbody id="actBody"></tbody></table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="dlgNews">
|
||||
<div class="head"><h2 id="newsTitle">Novedades</h2><div class="spacer"></div></div>
|
||||
<div class="body"><div id="newsBody"></div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px"><button class="primary" id="btnNewsOk">Entendido</button></div></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dlgSpeed">
|
||||
<div class="head"><h2>Test de velocidad</h2><div class="spacer"></div><button class="small" id="btnSpeedClose">Cerrar</button></div>
|
||||
<div class="body">
|
||||
@@ -235,6 +402,65 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
const I18N_EXACT={"Novedades": "What's new", "Entendido": "Got it", "Se ha actualizado. Esto es lo nuevo:": "PS5 Directo has been updated. Here is what's new:", "Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa más y qué puedes borrar. Actividad guarda el historial.": "Transfers is the queue. In Console files you browse the PS5: see sizes, copy, move, rename, delete, download to the PC or upload files and folders. Space shows in a chart what takes up the most room and what you can delete. Activity keeps the history.", "Espacio": "Space", "Almacenamiento interno": "Internal storage", "Analizar": "Analyze", "Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.": "See what takes up space on the console, with names and sizes, to decide what to delete. It flags repeated games (for example a folder and its converted version), half-sent transfers and leftovers. Needs BFpilot.", "Todo lo que ocupa sitio": "Everything that takes up space", "Borrar seleccionados": "Delete selected", "Disco externo": "External drive", "Libre": "Free", "Abrir": "Open", "Borrando…": "Deleting…", "¿Borrar de la consola? No se puede deshacer.": "Delete from the console? This cannot be undone.", "Juegos instalados desde la PS5": "Games installed from the PS5", "Sistema y otros datos de la consola": "System and other console data", "Otros": "Other", "Borrado. Vuelvo a medir el espacio…": "Deleted. Measuring the space again…", "Envía archivos a tu consola por la red local, sin pasar por el disco del PC": "Send files to your console over your local network, without going through your PC's disk", "Guía": "Guide", "Guía rápida: para qué es cada cosa": "Quick guide: what each part does", "Extensión": "Extension", "Capturar enlaces desde el navegador sin copiar y pegar": "Capture links from your browser without copy and paste", "Actualizar": "Update", "Buscar o instalar una versión nueva": "Check for or install a new version", "Ajustes": "Settings", "Salir": "Quit", "Cierra PS5 Directo del todo": "Closes PS5 Directo completely", "Nueva transferencia": "New transfer", "Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…": "Direct links (one per line). Paste all parts together: .part1.rar, .part2.rar… or .001, .002…", "https://servidor/archivo.part1.rar": "https://server/file.part1.rar", "https://servidor/archivo.part2.rar": "https://server/file.part2.rar", "También vale la ruta de un archivo de tu PC, por ejemplo: D:\\Descargas\\archivo.rar": "A file path on your PC also works, for example: D:\\Downloads\\file.rar", "Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el": "Files on your PC: drop them on this page and I'll find them on your PC, or drag them onto the", "icono de PS5 Directo": "PS5 Directo icon", ", o clic derecho →": ", or right-click →", "Enviar a → PS5 Directo": "Send to → PS5 Directo", "Contraseña del comprimido (si la tiene)": "Archive password (if any)", "Opcional. Si la dejas vacía prueba las de Ajustes y las del nombre": "Optional. If empty, it tries the ones in Settings and the one in the file name", "Destino en la PS5": "Destination on the PS5", "Dónde descomprimir": "Where to extract", "En la PS5 (BFpilot)": "On the PS5 (BFpilot)", "En el PC (7-Zip)": "On the PC (7-Zip)", "Añadir a la cola": "Add to queue", "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).": "If you paste links to several different files, it splits them automatically. \"On the PC\" uses 7-Zip and sends the content already extracted and verified (more data over Wi-Fi, but no BFpilot needed).", "Cola": "Queue", "Quitar terminados": "Clear finished", "No hay nada en la cola. Pega enlaces arriba.": "The queue is empty. Paste links above.", "Transferencia": "Transfer", "Pausar": "Pause", "Reanudar": "Resume", "Cancelar": "Cancel", "Reintentar": "Retry", "Quitar": "Remove", "Test de velocidad": "Speed test", "Mide descarga, envío y las dos cosas a la vez": "Measures download, upload and both at once", "Bajar al PC": "Download to PC", "Descomprimir y enviar": "Extract and send", "Colocar": "Place", "Listo": "Done", "Enviar": "Send", "Descomprimir": "Extract", "Enviado": "Sent", "de": "of", "Velocidad a la PS5": "Speed to PS5", "Quedan": "Remaining", "Descompresión en la PS5": "Extraction on the PS5", "Archivos y registro": "Files and log", "Copiar registro": "Copy log", "Registro copiado. Pégalo donde quieras (Ctrl+V).": "Log copied. Paste it anywhere (Ctrl+V).", "Registro copiado.": "Log copiLine truncated
|
||||
const I18N_RX=[["^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\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
|
||||
// --- idioma: español (original) o inglés. Traduce el texto de la página y los mensajes del programa.
|
||||
const LANG=(()=>{try{const v=localStorage.getItem('ps5d_lang');if(v==='es'||v==='en')return v}catch(e){}
|
||||
return (navigator.language||'es').toLowerCase().startsWith('es')?'es':'en'})();
|
||||
document.documentElement.lang=LANG;
|
||||
const I18N_RXC=I18N_RX.map(([a,b])=>[new RegExp(a),b]);
|
||||
const I18N_CACHE=new Map();
|
||||
function trCore(c){
|
||||
if(I18N_EXACT[c]!==undefined) return I18N_EXACT[c];
|
||||
for(const [rx,en] of I18N_RXC){const m=rx.exec(c); if(m) return en.replace(/\{(\d)\}/g,(_,i)=>tr(m[+i+1]||''));}
|
||||
return null;
|
||||
}
|
||||
function tr(s){
|
||||
if(LANG!=='en'||s==null) return s;
|
||||
s=String(s); if(!/[a-záéíóúñ¿¡]/i.test(s)) return s;
|
||||
if(I18N_CACHE.has(s)) return I18N_CACHE.get(s);
|
||||
const lead=s.match(/^\s*(?:[:·—]\s+)?/)[0], trail=s.match(/\s*$/)[0];
|
||||
const core=s.slice(lead.length, s.length-trail.length);
|
||||
let r=core?trCore(core):'';
|
||||
if(r==null){
|
||||
if(core.includes('\n')) r=core.split('\n').map(trLine).join('\n');
|
||||
else{
|
||||
for(const sep of [' -- ',' · ']){ if(core.includes(sep)){ r=core.split(sep).map(tr).join(sep); break } }
|
||||
if(r==null){ const parts=core.split(/(?<=[.!?:])\s+(?=[A-ZÁÉÍÓÚ¿¡(])/); if(parts.length>1){ const t=parts.map(tr); r=t.join(' ') } }
|
||||
}
|
||||
}
|
||||
const out=lead+(r==null?core:r)+trail;
|
||||
if(I18N_CACHE.size>5000) I18N_CACHE.clear();
|
||||
I18N_CACHE.set(s,out); return out;
|
||||
}
|
||||
function trLine(l){const m=/^(\d\d:\d\d:\d\d )([\s\S]*)$/.exec(l); return m?m[1]+tr(m[2]):tr(l)}
|
||||
const I18N_SKIP={SCRIPT:1,STYLE:1,TEXTAREA:1};
|
||||
function trText(n){
|
||||
const p=n.parentNode; if(!p||I18N_SKIP[p.nodeName]||(p.closest&&p.closest('[data-noi18n]'))) return;
|
||||
const v=n.nodeValue; const nv=(p.nodeName==='PRE')?v.split('\n').map(trLine).join('\n'):tr(v);
|
||||
if(nv!==v) n.nodeValue=nv;
|
||||
}
|
||||
function trAttrs(el){ for(const a of ['placeholder','title']){ const v=el.getAttribute&&el.getAttribute(a); if(v){ const nv=tr(v); if(nv!==v) el.setAttribute(a,nv) } } }
|
||||
function trTree(root){
|
||||
if(LANG!=='en'||!root) return;
|
||||
if(root.nodeType===3){trText(root);return}
|
||||
if(root.nodeType!==1||I18N_SKIP[root.nodeName]||(root.closest&&root.closest('[data-noi18n]'))) return;
|
||||
trAttrs(root); root.querySelectorAll('[placeholder],[title]').forEach(trAttrs);
|
||||
const w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let n; while((n=w.nextNode())) trText(n);
|
||||
}
|
||||
if(LANG==='en'){
|
||||
trTree(document.body);
|
||||
new MutationObserver(ms=>{for(const m of ms){
|
||||
if(m.type==='characterData') trText(m.target);
|
||||
else if(m.type==='attributes') trAttrs(m.target);
|
||||
else m.addedNodes.forEach(trTree);
|
||||
}}).observe(document.body,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['placeholder','title']});
|
||||
const _c=window.confirm.bind(window), _p=window.prompt.bind(window);
|
||||
window.confirm=m=>_c(tr(m)); window.prompt=(m,d)=>_p(tr(m),d);
|
||||
}
|
||||
function setLang(l){try{localStorage.setItem('ps5d_lang',l)}catch(e){} location.reload()}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const TOKEN = new URLSearchParams(location.hash.slice(1)).get('t') || '';
|
||||
@@ -262,6 +488,12 @@ function pcStepsHtml(j){
|
||||
const keys=labels.map(x=>x[0]); const ci=keys.indexOf(cur);
|
||||
return '<div class="steps">'+labels.map(([k,l],i)=>{const cls=j.status==='hecho'||i<ci?'done':(i===ci&&!['error','cancelado','pausado'].includes(j.status)?'on':'');return `<span class="step ${cls}">${l}</span>`}).join('')+'</div>';
|
||||
}
|
||||
function folderStepsHtml(j){
|
||||
// carpetas y envios "tal cual": comparar -> enviar -> listo
|
||||
const labels=['Comparar','Enviar','Listo'];
|
||||
let ci=j.status==='hecho'?3:(['en cola para descomprimir','colocando'].includes(j.status)?2:(j.status==='enviando'&&!/^Compar/.test(j.phase||'')?1:0));
|
||||
return '<div class="steps">'+labels.map((l,i)=>{const cls=j.status==='hecho'||i<ci?'done':(i===ci&&!['error','cancelado','pausado'].includes(j.status)?'on':'');return `<span class="step ${cls}">${l}</span>`}).join('')+'</div>';
|
||||
}
|
||||
function stepsHtml(st){
|
||||
const labels=[['enviando','Enviar'],['extrayendo','Descomprimir'],['colocando','Colocar'],['hecho','Listo']];
|
||||
const idx=ORDER.indexOf(st);
|
||||
@@ -290,7 +522,7 @@ function render(){
|
||||
const canPause = ['pendiente','comprobando','enviando'].includes(j.status);
|
||||
const paused = j.status==='pausado';
|
||||
return `<div class="job" data-id="${j.id}">
|
||||
<div class="top"><span class="title">${esc(j.title|| (j.sources&&j.sources[0]&&j.sources[0].name) || 'Transferencia')}</span>
|
||||
<div class="top"><span class="title" data-noi18n>${esc(j.title|| (j.sources&&j.sources[0]&&j.sources[0].name) || 'Transferencia')}</span>
|
||||
<span class="chip ${esc(j.status.split(' ')[0])}">${esc(j.status)}</span><div class="spacer"></div>
|
||||
${canPause?`<button class="small" data-act="pause">Pausar</button>`:''}
|
||||
${paused?`<button class="small" data-act="retry">Reanudar</button>`:''}
|
||||
@@ -299,7 +531,7 @@ function render(){
|
||||
${(!canPause && (j.sources||[]).some(x=>x.kind==='url') && !['extrayendo','colocando'].includes(j.status))?`<button class="small" data-act="speed" title="Mide descarga, envío y las dos cosas a la vez">Test de velocidad</button>`:''}
|
||||
${!canCancel?`<button class="small" data-act="remove">Quitar</button>`:''}
|
||||
</div>
|
||||
${(j.pc_step||j.pcx)?pcStepsHtml(j):stepsHtml(j.status)}
|
||||
${(j.raw||((j.sources||[])[0]||{}).kind==='folder')?folderStepsHtml(j):(j.pc_step||j.pcx)?pcStepsHtml(j):stepsHtml(j.status)}
|
||||
<div class="phase">${esc(j.phase)}</div>
|
||||
<div class="bar"><i style="width:${j.status==='hecho'?100:pct.toFixed(1)}%"></i></div>
|
||||
<div class="stats"><span>${esc(j.sent_label||'Enviado')} <b>${human(j.sent)}</b> de <b>${human(j.total)}</b></span>
|
||||
@@ -307,9 +539,9 @@ function render(){
|
||||
${showEx?`<div class="bar ex"><i style="width:${j.extract_pct||0}%"></i></div><div class="stats"><span>Descompresión en la PS5 <b>${j.extract_pct||0}%</b></span><span>${esc(j.extract_text||'')}</span></div>`:''}
|
||||
${j.warning?`<div class="warn">${esc(j.warning)}</div>`:''}
|
||||
${j.error?`<div class="err">${esc(j.error)}</div>`:''}
|
||||
${(j.result||[]).length?`<div class="result">${j.result.map(esc).join('<br>')}</div>`:''}
|
||||
${(j.result||[]).length?`<div class="result" data-noi18n>${j.result.map(esc).join('<br>')}</div>`:''}
|
||||
<details ${openLogs.has(j.id)?'open':''} data-log="${j.id}"><summary>Archivos y registro</summary>
|
||||
<div class="hint" style="margin-top:6px">${files||'—'}</div>
|
||||
<div class="hint" style="margin-top:6px" data-noi18n>${files||'—'}</div>
|
||||
<pre class="log">${esc((j.log||[]).slice(-120).join('\n'))}</pre>
|
||||
<button class="small" data-act="copylog" style="margin-top:6px">Copiar registro</button></details>
|
||||
</div>`}).join('');
|
||||
@@ -384,6 +616,7 @@ const TOUR=[
|
||||
{t:'#btnExtHead',h:'Extensión del navegador',p:'Opcional. Con ella, al pulsar Descargar en una web, el enlace llega aquí directo, sin copiar y pegar. Este botón abre su carpeta y las instrucciones.'},
|
||||
{t:'#btnUpdHead',h:'Actualizar',p:'Busca la versión nueva e instálala con un clic. Guarda una copia de la anterior por si acaso.'},
|
||||
{t:'#btnSettings',h:'Ajustes',p:'Lo primero: la IP de tu PS5. Ahí están también el límite de velocidad, el espacio que dejar libre, "Revisión completa" (comprueba que todo funciona), "Reparar permisos" y "Limpieza".'},
|
||||
{t:'#tabs',h:'Pestañas',p:'Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa más y qué puedes borrar. Actividad guarda el historial.'},
|
||||
{t:'#btnTour',h:'¡Listo!',p:'Puedes volver a ver esta guía cuando quieras con este botón. Ahora vamos a poner la IP de tu PS5.'},
|
||||
];
|
||||
let tourI=0, tourEls=null, tourDone=null;
|
||||
@@ -398,10 +631,10 @@ function tourPlace(){
|
||||
if(!tourEls) return;
|
||||
const [blk,spot,box]=tourEls, st=TOUR[tourI];
|
||||
const el=st.t&&document.querySelector(st.t);
|
||||
box.querySelector('h3').textContent=st.h; box.querySelector('p').textContent=st.p;
|
||||
box.querySelector('.tb-step').textContent=(tourI+1)+' de '+TOUR.length;
|
||||
box.querySelector('h3').textContent=tr(st.h); box.querySelector('p').textContent=tr(st.p);
|
||||
box.querySelector('.tb-step').textContent=tr((tourI+1)+' de '+TOUR.length);
|
||||
box.querySelector('[data-t=back]').hidden=tourI===0;
|
||||
box.querySelector('[data-t=next]').textContent=tourI===TOUR.length-1?'Empezar':'Siguiente';
|
||||
box.querySelector('[data-t=next]').textContent=tr(tourI===TOUR.length-1?'Empezar':'Siguiente');
|
||||
const bw=box.offsetWidth, bh=box.offsetHeight, W=innerWidth, H=innerHeight;
|
||||
if(!el){spot.className='tour-spot none';spot.style.left=W/2+'px';spot.style.top=H/2+'px';
|
||||
box.style.left=Math.max(16,(W-bw)/2)+'px';box.style.top=Math.max(16,(H-bh)/2)+'px';return}
|
||||
@@ -438,10 +671,12 @@ function startTour(onDone){
|
||||
}
|
||||
function needIp(){ if(state.settings && !state.settings.ps5_ip){$('btnSettings').click();setTimeout(()=>{try{$('s_ps5_ip').focus()}catch(e){}},100);toast('Pon la IP de tu PS5 y pulsa Guardar')} }
|
||||
$('btnTour').onclick=()=>startTour();
|
||||
$('btnLang').textContent=LANG==='en'?'Español':'English';
|
||||
$('btnLang').onclick=()=>setLang(LANG==='en'?'es':'en');
|
||||
document.addEventListener('keydown',e=>{ if(!tourEls) return;
|
||||
if(e.key==='Escape') tourEnd(); else if(e.key==='ArrowRight'||e.key==='Enter'){e.preventDefault();tourI===TOUR.length-1?tourEnd():tourGo(tourI+1)} else if(e.key==='ArrowLeft') tourGo(tourI-1)});
|
||||
refresh().then(()=>{
|
||||
if(state.settings && !state.settings.tour_done) startTour(needIp); else needIp();
|
||||
if(state.settings && !state.settings.tour_done) startTour(needIp); else { needIp(); showNews(); }
|
||||
checkUpdateQuiet();
|
||||
}); checkConn();
|
||||
setInterval(refresh, 1000); setInterval(checkConn, 20000);
|
||||
@@ -548,7 +783,7 @@ function notifyChanges(){
|
||||
if(j.status==='hecho'||j.status==='error'){
|
||||
const title=j.status==='hecho'?'Listo en la PS5':'Problema en PS5 Directo';
|
||||
const body=(j.title||'Transferencia')+(j.status==='error'?': '+(j.error||'').slice(0,120):'');
|
||||
try{ if('Notification' in window && Notification.permission==='granted') new Notification(title,{body,icon:'/favicon.ico'}); }catch(e){}
|
||||
try{ if('Notification' in window && Notification.permission==='granted') new Notification(tr(title),{body:tr(body),icon:'/favicon.ico'}); }catch(e){}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -622,5 +857,332 @@ function afterUpdate(v){
|
||||
document.body.innerHTML='<div style="padding:40px;font-family:system-ui,sans-serif;color:#ddd;background:#0d1117;min-height:100vh"><h2>Actualizado a la v'+esc(v)+'</h2><p>PS5 Directo se está reiniciando y abrirá una ventana nueva en unos segundos. Ya puedes cerrar esta.</p></div>';
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// ===================== pestañas =====================
|
||||
let curTab='Transfers';
|
||||
function showTab(t){
|
||||
curTab=t;
|
||||
['Transfers','Files','Space','Activity'].forEach(x=>{$('tab'+x).hidden=(x!==t)});
|
||||
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===t));
|
||||
try{localStorage.setItem('ps5d_tab',t)}catch(e){}
|
||||
if(t==='Files'&&!fm.loaded) fmGo(fm.path,true);
|
||||
if(t==='Activity') loadActivity();
|
||||
if(t==='Space') spEnter();
|
||||
}
|
||||
document.querySelectorAll('#tabs button').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
|
||||
|
||||
// ===================== elegir archivos / carpeta del PC =====================
|
||||
async function pick(kind){
|
||||
const title=kind==='folder'?tr('Elige la carpeta que quieres enviar'):tr('Elige los archivos que quieres enviar');
|
||||
toast(tr('Se ha abierto la ventana para elegir (puede salir detrás)'));
|
||||
const r=await api('/api/pick',{kind,title});
|
||||
if(!r.ok){toast(r.error);return []}
|
||||
return r.paths||[];
|
||||
}
|
||||
$('btnPickFiles').onclick=async()=>{const ps=await pick('files'); if(ps.length){const ta=$('lines');ta.value=(ta.value.trim()?ta.value.trim()+'\n':'')+ps.join('\n');toast(tr('Añadidas {0} ruta(s). Revisa destino y pulsa "Añadir a la cola".').replace('{0}',ps.length))}};
|
||||
$('btnPickFolder').onclick=async()=>{const ps=await pick('folder'); if(ps.length){const ta=$('lines');ta.value=(ta.value.trim()?ta.value.trim()+'\n':'')+ps.join('\n');toast(tr('Añadidas {0} ruta(s). Revisa destino y pulsa "Añadir a la cola".').replace('{0}',ps.length))}};
|
||||
|
||||
// ===================== novedades tras actualizar =====================
|
||||
async function showNews(){
|
||||
let r; try{r=await api('/api/whatsnew',{lang:LANG})}catch(e){return}
|
||||
if(!r.ok||!r.show) return;
|
||||
$('newsTitle').textContent=tr('Novedades de PS5 Directo {0}').replace('{0}',r.version);
|
||||
$('newsBody').innerHTML=`<p class="hint">${esc(tr('Se ha actualizado. Esto es lo nuevo:'))}</p>`+r.entries.map(e=>
|
||||
`<div class="news-v">${esc(tr('Versión {0}').replace('{0}',e.version))}</div><ul class="news-list" data-noi18n>${e.items.map(i=>`<li>${esc(i)}</li>`).join('')}</ul>`).join('');
|
||||
$('dlgNews').showModal();
|
||||
}
|
||||
$('btnNewsOk').onclick=()=>{$('dlgNews').close()};
|
||||
$('dlgNews').addEventListener('close',()=>api('/api/whatsnew/seen',{}).catch(()=>{}));
|
||||
|
||||
// ===================== espacio en la consola =====================
|
||||
const SP_COL=['#3987e5','#d95926','#199e70','#c98500','#d55181','#008300','#9085e9','#e66767'];
|
||||
const SP_REST='#56606d', SP_SYS='#3f4955', SP_INST='#6f7a87';
|
||||
const sp={res:null,slices:[],sel:new Set(),poll:null,drives:false,hl:-1};
|
||||
// nombre para ensenar: el titulo del juego y, si es una imagen, su tipo (para distinguir duplicados)
|
||||
function spName(x){const ext=(x.kind==='image'&&/\.([a-z0-9]+)$/i.exec(x.name)); return (x.title||tr(x.name))+(ext&&x.title?' · '+ext[1].toLowerCase():'')}
|
||||
async function spDrives(){
|
||||
if(sp.drives) return; sp.drives=true;
|
||||
try{const r=await api('/api/places',{}); const roots=[];
|
||||
(r.dests||[]).forEach(d=>{const m=d.match(/^\/mnt\/(usb|ext)\d+/); if(m&&!roots.includes(m[0]))roots.push(m[0])});
|
||||
$('spRoot').innerHTML=`<option value="/data">${esc(tr('Almacenamiento interno'))}</option>`+roots.map(x=>`<option value="${esc(x)}">${esc(tr('Disco externo'))} ${esc(x)}</option>`).join('');
|
||||
}catch(e){}
|
||||
}
|
||||
function spEnter(){
|
||||
spDrives();
|
||||
spPoll().then(st=>{ if(st && !st.busy && !st.result && !st.error) spScan(); });
|
||||
}
|
||||
async function spScan(){
|
||||
const r=await api('/api/space/scan',{root:$('spRoot').value});
|
||||
if(!r.ok){toast(r.error);return}
|
||||
sp.sel.clear(); spWatch();
|
||||
}
|
||||
function spWatch(){clearInterval(sp.poll); sp.poll=setInterval(spPoll,800); spPoll()}
|
||||
async function spPoll(){
|
||||
let r; try{r=await api('/api/space/state',{})}catch(e){return null}
|
||||
const st=r.space;
|
||||
$('spProg').hidden=!st.busy; $('spScan').disabled=st.busy;
|
||||
if(st.busy){
|
||||
$('spPhase').textContent=tr('Midiendo {0} de {1}: {2}').replace('{0}',st.done+1).replace('{1}',st.total||'?').replace('{2}',st.current||'');
|
||||
$('spBar').style.width=(st.total?st.done*100/st.total:3)+'%';
|
||||
return st;
|
||||
}
|
||||
clearInterval(sp.poll);
|
||||
if(st.error){$('spOut').innerHTML=`<p class="err">${esc(st.error)}</p>`;return st}
|
||||
if(st.result && st.result!==sp.res){ if(!sp.res||st.result.t!==sp.res.t){sp.res=st.result; spRender()} }
|
||||
return st;
|
||||
}
|
||||
function spSlices(r){
|
||||
const items=r.items.filter(x=>x.kind!=='system'&&x.size>0), sys=r.items.filter(x=>x.kind==='system'&&x.size>0);
|
||||
const out=[]; items.slice(0,8).forEach((x,i)=>out.push({name:spName(x),size:x.size,color:SP_COL[i],item:x}));
|
||||
const rest=items.slice(8); const restSz=rest.reduce((a,x)=>a+x.size,0);
|
||||
if(restSz>0) out.push({name:tr('Resto ({0} elementos)').replace('{0}',rest.length),size:restSz,color:SP_REST});
|
||||
sys.forEach(x=>out.push({name:tr(x.name),size:x.size,color:SP_INST,item:x}));
|
||||
if(r.other>0) out.push({name:r.root==='/data'?tr('Sistema y otros datos de la consola'):tr('Otros'),size:r.other,color:SP_SYS});
|
||||
if(r.free>0) out.push({name:tr('Libre'),size:r.free,color:'free'});
|
||||
return out;
|
||||
}
|
||||
function spArc(cx,cy,R,r,a0,a1){
|
||||
const p=(a,rad)=>[cx+rad*Math.sin(a),cy-rad*Math.cos(a)], big=(a1-a0)>Math.PI?1:0;
|
||||
if(a1-a0>=Math.PI*2-1e-6) a1=a0+Math.PI*2-1e-4;
|
||||
const [x0,y0]=p(a0,R),[x1,y1]=p(a1,R),[x2,y2]=p(a1,r),[x3,y3]=p(a0,r);
|
||||
return `M${x0} ${y0}A${R} ${R} 0 ${big} 1 ${x1} ${y1}L${x2} ${y2}A${r} ${r} 0 ${big} 0 ${x3} ${y3}Z`;
|
||||
}
|
||||
function spCenter(i){
|
||||
const r=sp.res, c=$('spCenter'); if(!c) return;
|
||||
if(i<0||!sp.slices[i]){c.innerHTML=`<b>${esc(human(r.free))}</b><span>${esc(tr('libres de {0}').replace('{0}',human(r.total)))}</span>`;return}
|
||||
const s=sp.slices[i]; c.innerHTML=`<b>${esc(human(s.size))}</b><span>${esc(s.name)}<br>${(s.size*100/Math.max(1,r.total)).toFixed(1)}%</span>`;
|
||||
}
|
||||
function spHL(i){
|
||||
sp.hl=i; const d=document.querySelector('.sp-donut');
|
||||
if(d){d.classList.toggle('hl',i>=0); d.querySelectorAll('path').forEach((p,k)=>p.classList.toggle('on',k===i))}
|
||||
document.querySelectorAll('.sp-legend .row').forEach((row,k)=>row.classList.toggle('on',k===i));
|
||||
spCenter(i);
|
||||
}
|
||||
function spRender(){
|
||||
const r=sp.res; sp.slices=spSlices(r);
|
||||
const tot=Math.max(1,sp.slices.reduce((a,x)=>a+x.size,0));
|
||||
let a=0; const paths=sp.slices.map((s,i)=>{const a0=a, a1=a+s.size/tot*Math.PI*2; a=a1;
|
||||
const free=s.color==='free';
|
||||
return `<path d="${spArc(140,140,132,86,a0,a1)}" fill="${free?'#1c232c':s.color}" stroke="${free?'#5b6573':'#161b22'}" stroke-width="2" ${free?'stroke-dasharray="4 3"':''} data-i="${i}"></path>`}).join('');
|
||||
const dups=r.items.filter(x=>x.dup).length, bad=r.items.filter(x=>x.kind==='incomplete'||x.kind==='leftover').length;
|
||||
let warn='';
|
||||
if(dups) warn+=`<p class="sp-note dup">⚠ ${esc(tr('Hay {0} elementos repetidos (mismo juego dos veces): revisa la lista.').replace('{0}',dups))}</p>`;
|
||||
if(bad) warn+=`<p class="sp-note bad">⚠ ${esc(tr('Hay {0} envíos a medias o restos que puedes borrar.').replace('{0}',bad))}</p>`;
|
||||
$('spOut').innerHTML=`<div class="sp-grid">
|
||||
<div class="sp-donut"><svg width="280" height="280" viewBox="0 0 280 280" role="img" aria-label="${esc(tr('Espacio'))}">${paths}</svg><div class="sp-center" id="spCenter"></div></div>
|
||||
<div><div class="sp-legend">${sp.slices.map((s,i)=>`<div class="row" data-i="${i}"><span class="sw ${s.color==='free'?'free':''}" style="${s.color==='free'?'':'background:'+s.color}"></span><span class="nm" data-noi18n>${esc(s.name)}</span><span class="sz">${esc(human(s.size))} · ${(s.size*100/Math.max(1,r.total)).toFixed(1)}%</span></div>`).join('')}</div>
|
||||
<p class="hint" style="margin-top:10px">${esc(tr('Usado {0} de {1}. Analizado: {2}').replace('{0}',human(r.used)).replace('{1}',human(r.total)).replace('{2}',r.root))}</p>${warn}</div></div>`;
|
||||
spCenter(-1);
|
||||
const tip=$('spTip')||Object.assign(document.body.appendChild(document.createElement('div')),{id:'spTip',className:'sp-tip',hidden:true});
|
||||
document.querySelectorAll('.sp-donut path').forEach(p=>{
|
||||
const i=+p.dataset.i, s=sp.slices[i];
|
||||
p.onmouseenter=()=>spHL(i); p.onmouseleave=()=>{spHL(-1);tip.hidden=true};
|
||||
p.onmousemove=ev=>{tip.hidden=false; tip.innerHTML=`<b data-noi18n>${esc(s.name)}</b><br>${esc(human(s.size))} · ${(s.size*100/Math.max(1,r.total)).toFixed(1)}%${s.item?`<br><span class="hint" data-noi18n>${esc(s.item.path)}</span>`:''}`;
|
||||
tip.style.left=Math.min(innerWidth-330,ev.clientX+14)+'px'; tip.style.top=(ev.clientY+14)+'px'};
|
||||
p.onclick=()=>{ if(s.item){const row=document.querySelector(`#spBody tr[data-p="${CSS.escape(s.item.path)}"]`); if(row){row.scrollIntoView({block:'center',behavior:'smooth'});row.classList.add('sel');setTimeout(()=>row.classList.remove('sel'),1500)}} };
|
||||
});
|
||||
document.querySelectorAll('.sp-legend .row').forEach(row=>{const i=+row.dataset.i; row.onmouseenter=()=>spHL(i); row.onmouseleave=()=>spHL(-1);
|
||||
row.onclick=()=>document.querySelectorAll('.sp-donut path')[i]?.onclick()});
|
||||
spTable();
|
||||
}
|
||||
function spTable(){
|
||||
const r=sp.res, col={}; sp.slices.forEach(s=>{if(s.item&&s.color!=='free')col[s.item.path]=s.color});
|
||||
$('spListCard').hidden=false;
|
||||
$('spBody').innerHTML=r.items.filter(x=>x.size>0||x.kind==='incomplete').map(x=>{
|
||||
const pct=x.size*100/Math.max(1,r.total);
|
||||
const notes=(x.notes||[]).map(n=>`<div class="sp-note ${/^Duplicado/.test(n)?'dup':(x.kind==='incomplete'||x.kind==='leftover')?'bad':''}">${esc(tr(n))}</div>`).join('');
|
||||
const sw=col[x.path]?`<span class="sw" style="background:${col[x.path]}"></span> `:'';
|
||||
return `<tr data-p="${esc(x.path)}" class="${sp.sel.has(x.path)?'sel':''}">
|
||||
<td>${x.deletable?`<input type="checkbox" class="spck" ${sp.sel.has(x.path)?'checked':''}>`:''}</td>
|
||||
<td><div>${sw}<b data-noi18n>${esc(spName(x))}</b></div><div class="hint mono" data-noi18n style="font-family:Consolas,monospace">${esc(x.path)}</div>${notes}
|
||||
<div class="sp-pct"><i style="width:${Math.max(.5,Math.min(100,pct)).toFixed(1)}%;background:${col[x.path]||SP_REST}"></i></div></td>
|
||||
<td class="num">${esc(human(x.size))}<div class="hint">${pct.toFixed(1)}%</div></td>
|
||||
<td class="num">${x.kind!=='system'?`<button class="small spopen">${esc(tr('Abrir'))}</button> `:''}${x.deletable?`<button class="small spdel">${esc(tr('Borrar'))}</button>`:''}</td></tr>`}).join('');
|
||||
$('spBody').querySelectorAll('tr').forEach(tr_=>{const p=tr_.dataset.p;
|
||||
const ck=tr_.querySelector('.spck'); if(ck) ck.onchange=()=>{ck.checked?sp.sel.add(p):sp.sel.delete(p); tr_.classList.toggle('sel',ck.checked); spSelInfo()};
|
||||
const o=tr_.querySelector('.spopen'); if(o) o.onclick=()=>{const it=sp.res.items.find(x=>x.path===p); showTab('Files'); fmGo(it&&it.dir?p:parentOf(p))};
|
||||
const d=tr_.querySelector('.spdel'); if(d) d.onclick=()=>spDelete([p]);
|
||||
});
|
||||
spSelInfo();
|
||||
}
|
||||
function spSelInfo(){
|
||||
const sz=sp.res?sp.res.items.filter(x=>sp.sel.has(x.path)).reduce((a,x)=>a+x.size,0):0;
|
||||
$('spDel').disabled=!sp.sel.size;
|
||||
$('spDel').textContent=sp.sel.size?tr('Borrar seleccionados ({0}, {1})').replace('{0}',sp.sel.size).replace('{1}',human(sz)):tr('Borrar seleccionados');
|
||||
}
|
||||
async function spDelete(paths){
|
||||
const its=sp.res.items.filter(x=>paths.includes(x.path)), sz=its.reduce((a,x)=>a+x.size,0);
|
||||
const list=its.slice(0,12).map(x=>'• '+(x.title?x.title+' — ':'')+x.path+' ('+human(x.size)+')').join('\n')+(its.length>12?'\n…':'');
|
||||
if(!confirm(tr('¿Borrar de la consola? No se puede deshacer.')+'\n\n'+list+'\n\n'+tr('Liberarás unos {0}.').replace('{0}',human(sz)))) return;
|
||||
const r=await api('/api/fm/delete',{paths}); if(!r.ok){toast(r.error);return}
|
||||
$('spProg').hidden=false; $('spCancel').hidden=true; $('spPhase').textContent=tr('Borrando…'); $('spBar').style.width='30%';
|
||||
for(;;){await new Promise(z=>setTimeout(z,800)); let o; try{o=(await api('/api/fm/op',{})).op}catch(e){continue}
|
||||
if(o.busy){$('spPhase').textContent=tr('Borrando…')+' '+(o.current||''); $('spBar').style.width=(o.items_total?Math.max(5,o.items_done*100/o.items_total):30)+'%'; continue}
|
||||
$('spCancel').hidden=false;
|
||||
if(o.error) toast(o.error); else toast(tr('Borrado. Vuelvo a medir el espacio…'));
|
||||
break}
|
||||
paths.forEach(p=>sp.sel.delete(p)); spScan();
|
||||
}
|
||||
$('spScan').onclick=spScan;
|
||||
$('spCancel').onclick=()=>api('/api/space/cancel',{});
|
||||
$('spDel').onclick=()=>spDelete([...sp.sel]);
|
||||
$('spRoot').onchange=spScan;
|
||||
|
||||
// ===================== explorador de la consola =====================
|
||||
const fm={path:'/data/homebrew',entries:[],back:[],fwd:[],sel:new Set(),sizes:{},sort:'name',asc:true,clip:null,loaded:false,busy:false};
|
||||
const ICON_DIR='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7fb0ff" stroke-width="2" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 8.7v9.8A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5z"/></svg>';
|
||||
const ICON_FILE='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b97a5" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/></svg>';
|
||||
function joinPath(a,b){return (a.replace(/\/+$/,'')+'/'+b).replace(/\/+/g,'/')}
|
||||
function parentOf(p){const x=p.replace(/\/+$/,'').split('/');x.pop();return x.join('/')||'/'}
|
||||
function fmtDate(t){if(!t)return '';const d=new Date(t*1000);return d.toLocaleDateString()+' '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}
|
||||
function fmtMode(m){return m==null?'':(m&0o777).toString(8)}
|
||||
async function fmGo(path,noHist){
|
||||
path=(path||'/').trim()||'/';
|
||||
const hint=$('fmHint'); hint.textContent=tr('Cargando…');
|
||||
let r; try{r=await api('/api/fm/list',{path})}catch(e){hint.textContent=e.message;return}
|
||||
if(!r.ok){hint.textContent=r.error;return}
|
||||
if(!noHist && fm.path!==r.path){fm.back.push(fm.path);fm.fwd=[]}
|
||||
fm.path=r.path; fm.entries=r.entries; fm.sel.clear(); fm.loaded=true;
|
||||
$('fmPath').value=fm.path;
|
||||
if(r.total){const used=r.total-r.free; $('fmStore').innerHTML=`<b>${human(r.free)}</b> ${esc(tr('libres de'))} ${human(r.total)}<div class="bar"><i style="width:${Math.min(100,used*100/r.total).toFixed(1)}%"></i></div>`}
|
||||
else $('fmStore').textContent='';
|
||||
hint.textContent=r.via==='ftp'?tr('BFpilot no responde: solo lectura por FTP. Cárgalo para copiar, mover, borrar o calcular tamaños.'):'';
|
||||
fmRender();
|
||||
}
|
||||
function fmRender(){
|
||||
const q=$('fmSearch').value.trim().toLowerCase();
|
||||
let list=fm.entries.filter(e=>!q||e.name.toLowerCase().includes(q));
|
||||
const k=fm.sort, dir=fm.asc?1:-1;
|
||||
list.sort((a,b)=>{ if(a.dir!==b.dir) return a.dir?-1:1;
|
||||
let va=k==='size'?(a.dir?(fm.sizes[joinPath(fm.path,a.name)]??-1):a.size):k==='mtime'?a.mtime:a.name.toLowerCase();
|
||||
let vb=k==='size'?(b.dir?(fm.sizes[joinPath(fm.path,b.name)]??-1):b.size):k==='mtime'?b.mtime:b.name.toLowerCase();
|
||||
return (va>vb?1:va<vb?-1:0)*dir});
|
||||
const body=$('fmBody');
|
||||
if(!list.length){body.innerHTML=`<tr><td colspan="6" class="hint" style="padding:18px;text-align:center">${esc(tr(q?'Nada coincide con la búsqueda.':'Carpeta vacía.'))}</td></tr>`}
|
||||
else body.innerHTML=list.map(e=>{
|
||||
const full=joinPath(fm.path,e.name), sz=e.dir?fm.sizes[full]:e.size;
|
||||
return `<tr data-p="${esc(full)}" data-dir="${e.dir?1:0}" class="${fm.sel.has(full)?'sel':''}">
|
||||
<td><input type="checkbox" ${fm.sel.has(full)?'checked':''}></td>
|
||||
<td><div class="fname ${e.dir?'dir':''}" data-noi18n>${e.dir?ICON_DIR:ICON_FILE}<span>${esc(e.name)}</span></div></td>
|
||||
<td class="num">${sz!=null?human(sz):(e.dir?`<button class="linkbtn" data-a="du">${esc(tr('calcular'))}</button>`:'')}</td>
|
||||
<td class="hide-s mono">${fmtDate(e.mtime)}</td>
|
||||
<td class="hide-s mono">${fmtMode(e.mode)}</td>
|
||||
<td><div class="rowact"><button class="small" data-a="ren">${esc(tr('Renombrar'))}</button><button class="small" data-a="dl">${esc(tr('Descargar'))}</button><button class="small danger" data-a="del">${esc(tr('Borrar'))}</button></div></td></tr>`}).join('');
|
||||
$('fmAll').checked=list.length>0&&list.every(e=>fm.sel.has(joinPath(fm.path,e.name)));
|
||||
fmButtons();
|
||||
}
|
||||
function fmButtons(){
|
||||
const n=fm.sel.size;
|
||||
['fmCopy','fmCut','fmDownload','fmSize','fmPerms','fmDelete'].forEach(id=>$(id).disabled=n===0||fm.busy);
|
||||
$('fmRename').disabled=n!==1||fm.busy;
|
||||
const c=$('fmClip');
|
||||
if(fm.clip){c.hidden=false;c.innerHTML=`<span>${esc(tr(fm.clip.op==='copy'?'Para copiar:':'Para mover:'))} <b data-noi18n>${fm.clip.paths.length===1?esc(fm.clip.paths[0].split('/').pop()):fm.clip.paths.length+' '+esc(tr('elementos'))}</b></span><button class="small primary" id="fmPaste">${esc(tr('Pegar aquí'))}</button><button class="small" id="fmClipX">${esc(tr('Cancelar'))}</button>`;
|
||||
$('fmPaste').onclick=fmPaste; $('fmClipX').onclick=()=>{fm.clip=null;fmButtons()}}
|
||||
else c.hidden=true;
|
||||
}
|
||||
$('fmBody').addEventListener('click',async ev=>{
|
||||
const tr_=ev.target.closest('tr[data-p]'); if(!tr_) return;
|
||||
const p=tr_.dataset.p, isDir=tr_.dataset.dir==='1';
|
||||
const a=ev.target.closest('[data-a]');
|
||||
if(a){ev.stopPropagation();
|
||||
if(a.dataset.a==='du') return fmDu([p]);
|
||||
if(a.dataset.a==='ren') return fmRename(p);
|
||||
if(a.dataset.a==='dl') return fmOpStart('/api/fm/download',{paths:[p]});
|
||||
if(a.dataset.a==='del') return fmDelete([p]);
|
||||
}
|
||||
if(ev.target.type==='checkbox'){ ev.target.checked?fm.sel.add(p):fm.sel.delete(p); tr_.classList.toggle('sel',ev.target.checked); fmButtons(); return }
|
||||
if(ev.target.closest('.fname') && isDir) return fmGo(p);
|
||||
if(fm.sel.has(p)) fm.sel.delete(p); else fm.sel.add(p);
|
||||
fmRender();
|
||||
});
|
||||
$('fmBody').addEventListener('dblclick',ev=>{const tr_=ev.target.closest('tr[data-p]'); if(tr_&&tr_.dataset.dir==='1') fmGo(tr_.dataset.p)});
|
||||
$('fmAll').onchange=()=>{const q=$('fmSearch').value.trim().toLowerCase();
|
||||
fm.entries.filter(e=>!q||e.name.toLowerCase().includes(q)).forEach(e=>{const p=joinPath(fm.path,e.name); $('fmAll').checked?fm.sel.add(p):fm.sel.delete(p)}); fmRender()};
|
||||
document.querySelectorAll('.fmtable th[data-sort]').forEach(th=>th.onclick=()=>{const k=th.dataset.sort; fm.asc=fm.sort===k?!fm.asc:true; fm.sort=k; fmRender()});
|
||||
$('fmSearch').addEventListener('input',fmRender);
|
||||
$('fmPath').addEventListener('keydown',e=>{if(e.key==='Enter') fmGo($('fmPath').value)});
|
||||
$('fmRefresh').onclick=()=>fmGo(fm.path,true);
|
||||
$('fmUp').onclick=()=>fmGo(parentOf(fm.path));
|
||||
$('fmBack').onclick=()=>{if(!fm.back.length)return; fm.fwd.push(fm.path); fmGo(fm.back.pop(),true)};
|
||||
$('fmFwd').onclick=()=>{if(!fm.fwd.length)return; fm.back.push(fm.path); fmGo(fm.fwd.pop(),true)};
|
||||
$('fmPlaces').onchange=()=>{const v=$('fmPlaces').value; $('fmPlaces').value=''; if(v) fmGo(v)};
|
||||
async function fmPlaces(){
|
||||
const base=['/data/homebrew','/data','/data/pkg','/data/ps5directo','/mnt'];
|
||||
try{const r=await api('/api/places',{}); (r.dests||[]).forEach(d=>{if(!base.includes(d))base.push(d)})}catch(e){}
|
||||
$('fmPlaces').innerHTML=`<option value="">${esc(tr('Ir a…'))}</option>`+base.map(d=>`<option value="${esc(d)}">${esc(d)}</option>`).join('');
|
||||
}
|
||||
fmPlaces();
|
||||
function selList(){return [...fm.sel]}
|
||||
$('fmCopy').onclick=()=>{fm.clip={op:'copy',paths:selList()};fm.sel.clear();fmRender();toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí"'))};
|
||||
$('fmCut').onclick=()=>{fm.clip={op:'move',paths:selList()};fm.sel.clear();fmRender();toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí"'))};
|
||||
$('fmRename').onclick=()=>fmRename(selList()[0]);
|
||||
$('fmDownload').onclick=()=>fmOpStart('/api/fm/download',{paths:selList()});
|
||||
$('fmSize').onclick=()=>fmDu(selList().filter(p=>{const e=fm.entries.find(x=>joinPath(fm.path,x.name)===p);return e&&e.dir}));
|
||||
$('fmPerms').onclick=()=>fmOpStart('/api/fm/chmod',{paths:selList()});
|
||||
$('fmDelete').onclick=()=>fmDelete(selList());
|
||||
$('fmMkdir').onclick=async()=>{const n=prompt(tr('Nombre de la carpeta nueva:'),''); if(!n) return;
|
||||
const r=await api('/api/fm/mkdir',{path:joinPath(fm.path,n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); fmGo(fm.path,true)};
|
||||
async function fmRename(p){const old=p.split('/').pop(); const n=prompt(tr('Nuevo nombre:'),old); if(!n||n===old) return;
|
||||
const r=await api('/api/fm/rename',{src:p,dst:joinPath(parentOf(p),n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); fmGo(fm.path,true)}
|
||||
async function fmDu(paths){for(const p of paths){fm.sizes[p]=undefined; try{const r=await api('/api/fm/du',{path:p}); if(r.ok) fm.sizes[p]=r.size; else toast(r.error)}catch(e){toast(e.message)} fmRender()}}
|
||||
function fmDelete(paths){if(!paths.length) return;
|
||||
const names=paths.map(p=>p.split('/').pop()); const list=names.slice(0,6).join('\n')+(names.length>6?'\n…':'');
|
||||
if(!confirm(tr('¿Borrar {0} elemento(s) de la consola? No se puede deshacer.').replace('{0}',paths.length)+'\n\n'+list)) return;
|
||||
fmOpStart('/api/fm/delete',{paths})}
|
||||
async function fmPaste(){if(!fm.clip) return; const c=fm.clip;
|
||||
const r=await fmOpStart('/api/fm/paste',{op:c.op,srcs:c.paths,dst:fm.path}); if(r) {fm.clip=null; fmButtons()}}
|
||||
async function fmUpload(kind){const ps=await pick(kind); if(!ps.length) return;
|
||||
try{await api('/api/add',{lines:ps.join('\n'),dest:fm.path,raw:true}); toast(tr('Añadido a la cola: se sube tal cual a {0}').replace('{0}',fm.path)); refresh()}catch(e){toast(e.message)}}
|
||||
$('fmUpFiles').onclick=()=>fmUpload('files');
|
||||
$('fmUpFolder').onclick=()=>fmUpload('folder');
|
||||
// operaciones largas (copiar, mover, borrar, bajar, permisos) con progreso
|
||||
const OPNAME={copy:'Copiando',move:'Moviendo',delete:'Borrando',download:'Descargando al PC',chmod:'Poniendo permisos'};
|
||||
let fmPoll=null;
|
||||
async function fmOpStart(url,body){
|
||||
try{const r=await api(url,body); if(!r.ok){toast(r.error);return false}}catch(e){toast(e.message);return false}
|
||||
fm.busy=true; fmButtons(); fmWatch(); return true;
|
||||
}
|
||||
function fmWatch(){clearInterval(fmPoll); fmPoll=setInterval(fmPollOp,900); fmPollOp()}
|
||||
function fmtSecs(n){n=Math.max(0,Math.round(n));const m=Math.floor(n/60);return m?m+' min '+(n%60)+' s':n+' s'}
|
||||
async function fmPollOp(){
|
||||
let r; try{r=await api('/api/fm/op',{})}catch(e){return}
|
||||
const o=r.op, box=$('fmOp');
|
||||
if(!o.op){box.hidden=true;clearInterval(fmPoll);fm.busy=false;return}
|
||||
box.hidden=false;
|
||||
const pct=o.bytes_total>0?Math.min(100,o.bytes_done*100/o.bytes_total):(o.items_total?o.items_done*100/o.items_total:0);
|
||||
if(o.busy){
|
||||
box.innerHTML=`<b>${esc(tr(OPNAME[o.op]||o.op))}</b> <span class="hint" data-noi18n>${esc((o.current||'').split('/').pop())}</span>
|
||||
<div class="bar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||||
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}${o.op==='delete'?' · '+esc(tr('liberados {0}').replace('{0}',human(o.bytes_done||0)))+' · '+fmtSecs(Date.now()/1000-(o.t0||Date.now()/1000)):''}</span>
|
||||
<button class="small" id="fmOpCancel">${esc(tr('Cancelar'))}</button>`;
|
||||
$('fmOpCancel').onclick=()=>api('/api/fm/cancel',{});
|
||||
} else {
|
||||
clearInterval(fmPoll); fm.busy=false; fmButtons();
|
||||
box.innerHTML=o.error?`<span class="err" style="margin:0">${esc(o.error)}</span><button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`
|
||||
:`<span>✔ ${esc(o.message||tr('Hecho'))}</span>${o.op==='download'?`<button class="small" id="fmOpenLocal">${esc(tr('Abrir carpeta'))}</button>`:''}<button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`;
|
||||
$('fmOpOk').onclick=()=>{box.hidden=true};
|
||||
if($('fmOpenLocal')) $('fmOpenLocal').onclick=()=>api('/api/fm/open_local',{});
|
||||
if(o.op!=='download') fmGo(fm.path,true);
|
||||
}
|
||||
}
|
||||
fmWatch();
|
||||
|
||||
// ===================== actividad =====================
|
||||
async function loadActivity(){
|
||||
let r; try{r=await api('/api/history',{})}catch(e){return}
|
||||
const items=r.items||[];
|
||||
$('actBody').innerHTML=items.length?items.map(h=>{
|
||||
const ok=h.status==='hecho', d=new Date(h.t*1000), m=Math.floor((h.secs||0)/60);
|
||||
const dur=h.secs>=3600?Math.floor(h.secs/3600)+' h '+(m%60)+' min':(m?m+' min '+(h.secs%60)+' s':(h.secs||0)+' s');
|
||||
return `<tr><td class="mono">${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
||||
<td data-noi18n>${esc(h.title)}</td>
|
||||
<td><span class="chip ${ok?'hecho':'error'}">${esc(h.status)}</span>${h.error?`<div class="hint">${esc(h.error.slice(0,160))}</div>`:''}</td>
|
||||
<td class="num">${h.size>0?human(h.size):''}</td><td>${dur}</td>
|
||||
<td class="mono" data-noi18n>${esc((h.result||[]).join(', ')||h.dest||'')}</td></tr>`}).join('')
|
||||
:`<tr><td colspan="6" class="hint" style="padding:18px;text-align:center">${esc(tr('Todavía no hay nada en el historial.'))}</td></tr>`;
|
||||
}
|
||||
$('actClear').onclick=async()=>{if(!confirm(tr('¿Borrar todo el historial?')))return; await api('/api/history/clear',{}); loadActivity()};
|
||||
try{const t=localStorage.getItem('ps5d_tab'); if(t&&t!=='Transfers'&&$('tab'+t)) showTab(t)}catch(e){}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||