Compare commits

..
6 Commits
Author SHA1 Message Date
JesterDev c8a0466854 Version 1.18.1 2026-10-16 19:11:15 +02:00
JesterDev 3314a0f1b0 Version 1.18.0 2026-10-16 19:06:32 +02:00
JesterDev baa4616faf Version 1.18.0 2026-10-16 18:47:56 +02:00
JesterDev d9c33091ca Version 1.17.0 2026-10-16 16:38:42 +02:00
JesterDev 4fc970e7d8 Version 1.16.0 2026-10-16 15:41:04 +02:00
JesterDev 65911f790d Add files via upload 2026-10-16 14:07:39 +02:00
23 changed files with 3840 additions and 88 deletions

No files matched your search

+74 -4
View File
@@ -1,5 +1,6 @@
PS5 DIRECTO 1.14
PS5 DIRECTO 1.18
================
(English: see README_EN.txt)
Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola:
@@ -15,6 +16,31 @@ 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.
Copiar, mover, borrar y calcular tamaños necesitan BFpilot; sin él, el
explorador funciona en solo lectura por FTP.
- Espacio: gráfico de quesitos con lo que ocupa sitio en la consola, con
el nombre de cada juego y su tamaño. Avisa de juegos repetidos (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. Los juegos instalados desde la propia PS5 se ven pero se borran
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 +168,49 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
Ajustes.
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 juego y su tamaño. Marca juegos repetidos
(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 +279,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 +316,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.
+46 -18
View File
@@ -1,29 +1,57 @@
# 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
- ✅ 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: sizes, copy, move, rename, delete, download to PC, upload
- ✅ Storage view: a pie chart of what fills the console, by game 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
- **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.
## Requirements
- **PC:** Windows 10/11 and [Python 3.9+](https://www.python.org/downloads/) (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 `.zip` 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).
## 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).
---
## 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ú.
**Requisitos:** Windows 10/11, [Python 3.9+](https://www.python.org/downloads/) y, 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).
+269
View File
@@ -0,0 +1,269 @@
PS5 DIRECTO 1.18 — USER GUIDE (English)
=======================================
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.
Copy, move, delete and folder sizes need BFpilot; without it the
browser is read-only over FTP.
- Space: a pie chart of what takes up space on the console, with each
game's name and size. It flags repeated games (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.
Games installed from the PS5 itself are 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
WHAT'S NEW
----------
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 game name and size. It flags repeated games (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.
Binary file not shown.
+846 -13
View File
@@ -13,6 +13,7 @@ import sys
import threading
import time
import traceback
import urllib.parse
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -21,7 +22,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import engine as E # noqa: E402
VERSION = "1.14.1"
VERSION = "1.18.1"
# Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas,
# todas las copias buscan actualizaciones ahi sin tener que configurar nada.
DEFAULT_REPO = "jstrdev/ps5directo"
@@ -53,6 +54,7 @@ DEFAULTS = {
"guess_passwords": True,
"delete_archives": True,
"tour_done": False, # la guia de bienvenida ya se vio
"seen_version": "", # ultima version cuyas novedades ya se ensenaron
}
LOCK = threading.RLock()
@@ -112,8 +114,9 @@ def save_settings():
# Utilidades de trabajos
# ---------------------------------------------------------------------------
def new_job(lines, password, dest, title=None, where=None):
def new_job(lines, password, dest, title=None, where=None, raw=False):
return {
"raw": bool(raw),
"where": where if where in ("ps5", "pc") else "",
"id": secrets.token_hex(4),
"created": time.time(),
@@ -390,9 +393,13 @@ def transfer_job(job, srcs, cancel):
cfg = cfg_snapshot()
cfg["_log"] = lambda m: jlog(job, m)
cfg["_limit_bps"] = live_limit_bps
if job.get("raw"):
return raw_job(job, srcs, cancel)
if srcs[0].kind == "folder":
return folder_job(job, srcs[0], cancel)
names = [s.name for s in srcs]
archives = [s for s in srcs if E.is_archive(s.name)]
if archives and job_where(job) == "pc":
if archives and (job_where(job) == "pc" or any(E.is_tar(a.name) for a in archives)):
return pc_extract_job(job, srcs, cancel)
m = E.SPLIT_RAW_RE.match(srcs[0].name)
raw_split = (not archives and len(srcs) >= 1 and m
@@ -494,6 +501,651 @@ def transfer_job(job, srcs, cancel):
setj(job, speed=0.0, dl_speed=0.0)
# ---------------------------------------------------------------------------
# Envio "tal cual" (desde el explorador de la consola): sin descomprimir ni colocar
# ---------------------------------------------------------------------------
def raw_job(job, srcs, cancel):
cfg = cfg_snapshot()
cfg["_limit_bps"] = live_limit_bps
dest = (job.get("dest") or cfg["dest"]).rstrip("/") or "/"
total = sum(max(0, s.size) for s in srcs)
up, meter = Meter(), Meter()
state = {"base": 0, "last": 0}
stop_speed = threading.Event()
def speed_loop():
while not stop_speed.wait(1):
setj(job, speed=up.rate(), dl_speed=meter.rate())
threading.Thread(target=speed_loop, daemon=True).start()
def prog(v):
cur = state["base"] + v
if cur > state["last"]:
up(cur - state["last"])
state["last"] = cur
setj(job, sent=cur)
setj(job, status="enviando", total=total if total > 0 else -1, sent=0)
placed = []
try:
for s in srcs:
target = dest + "/" + s.name
if s.kind == "folder":
setj(job, phase="Enviando carpeta: %s" % s.name)
sent, skipped, resumed = E.upload_tree(cfg, s, target, cancel, lambda m: jlog(job, m),
lambda v, rel: prog(v))
jlog(job, "Carpeta enviada: %d archivos nuevos, %d ya estaban iguales, %d continuados"
% (sent, skipped, resumed))
else:
setj(job, phase="Enviando %s" % s.name)
E.upload(cfg, target, s.size, lambda off, s=s: s.stream(off, cfg, cancel, meter),
prog, cancel, lambda m: jlog(job, m))
state["base"] += max(0, s.size)
placed.append(target)
finally:
stop_speed.set()
setj(job, speed=0.0, dl_speed=0.0)
return {"mode": "done", "placed": placed, "game": False}
# ---------------------------------------------------------------------------
# Historial (pestana Actividad)
# ---------------------------------------------------------------------------
HISTORY_FILE = os.path.join(DATA_DIR, "history.json")
HISTORY_LOCK = threading.Lock()
def record_history(job):
try:
with HISTORY_LOCK:
try:
with open(HISTORY_FILE, encoding="utf-8") as f:
hist = json.load(f)
except Exception:
hist = []
hist.append({
"t": time.time(), "id": job["id"], "title": job.get("title") or "",
"status": job.get("status"), "size": job.get("total", -1),
"result": job.get("result") or [], "error": job.get("error") or "",
"secs": int(time.time() - float(job.get("started") or job.get("created") or time.time())),
"where": "raw" if job.get("raw") else ("pc" if (job.get("info") or {}).get("mode") == "pcx" else "ps5"),
"dest": job.get("dest") or "",
})
_save_json(HISTORY_FILE, hist[-500:])
except Exception:
traceback.print_exc()
def read_history():
try:
with open(HISTORY_FILE, encoding="utf-8") as f:
return list(reversed(json.load(f)))
except Exception:
return []
# ---------------------------------------------------------------------------
# Explorador de archivos de la consola
# ---------------------------------------------------------------------------
FMOP = {"busy": False, "op": "", "current": "", "items_done": 0, "items_total": 0,
"bytes_done": 0, "bytes_total": 0, "error": "", "message": "", "t0": 0.0, "local": ""}
FM_CANCEL = threading.Event()
def fm_clean(path):
path = "/" + "/".join(x for x in str(path or "/").replace("\\", "/").split("/") if x and x != ".")
if ".." in path.split("/"):
raise E.PipeError("Ruta no valida")
return path
def fm_list(path):
cfg = cfg_snapshot()
path = fm_clean(path)
bf = E.BFpilot(cfg)
out = {"path": path, "entries": [], "free": None, "total": None, "via": "bfpilot"}
if bf.alive():
ents = bf.listdir(path)
out["entries"] = [{"name": e.get("name"), "dir": bool(e.get("dir")), "size": e.get("size"),
"mtime": e.get("mtime"), "mode": e.get("mode")} for e in ents]
try:
st = bf._req("GET", "/api/fs/stat", {"path": path})
out["free"] = st.get("availableBytes")
out["total"] = st.get("usableTotalBytes") or st.get("totalBytes")
except Exception:
pass
else:
out["via"] = "ftp"
f = E.Ftp(cfg).connect()
try:
for name, facts in f.mlsd(path, facts=["type", "size", "modify", "unix.mode"]):
if facts.get("type") not in ("file", "dir"):
continue
mt = facts.get("modify")
try:
mt = int(time.mktime(time.strptime(mt[:14], "%Y%m%d%H%M%S"))) if mt else None
except Exception:
mt = None
mode = facts.get("unix.mode")
out["entries"].append({"name": name, "dir": facts.get("type") == "dir",
"size": int(facts.get("size") or 0), "mtime": mt,
"mode": int(mode, 8) if mode else None})
finally:
try:
f.quit()
except Exception:
pass
out["entries"].sort(key=lambda e: (not e["dir"], (e["name"] or "").lower()))
return out
def fm_bf():
bf = E.BFpilot(cfg_snapshot())
if not bf.alive():
raise E.PipeError("BFpilot no responde: cargalo para copiar, mover, borrar o calcular tamanos")
return bf
def _fm_start(op, items_total, fn):
with LOCK:
if FMOP["busy"]:
raise E.PipeError("Ya hay una operacion en marcha en el explorador")
FMOP.update(busy=True, op=op, current="", items_done=0, items_total=items_total, bytes_done=0,
bytes_total=0, error="", message="", t0=time.time(), local="")
FM_CANCEL.clear()
def run():
try:
fn()
with LOCK:
if not FMOP["message"]:
FMOP["message"] = "Hecho"
except E.Cancelled:
with LOCK:
FMOP["error"] = "Cancelado"
except Exception as e:
traceback.print_exc()
with LOCK:
FMOP["error"] = str(e)
finally:
with LOCK:
FMOP["busy"] = False
threading.Thread(target=run, daemon=True).start()
def fm_paste(op, srcs, dst, overwrite=False):
bf = fm_bf()
dst = fm_clean(dst)
srcs = [fm_clean(x) for x in srcs]
for s in srcs:
if dst == s or dst.startswith(s + "/"):
raise E.PipeError("No se puede %s una carpeta dentro de si misma" % ("mover" if op == "move" else "copiar"))
def work():
for i, s in enumerate(srcs):
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP.update(current=s, items_done=i, bytes_done=0, bytes_total=0)
if op == "move" and posixpath.dirname(s) == dst:
continue
bf._req("POST", "/api/fs/" + op, form={"src": s, "dst": dst, "overwrite": "1" if overwrite else "0"})
while True:
time.sleep(0.7)
if FM_CANCEL.is_set():
try:
bf._req("GET", "/api/fs/job/cancel")
except Exception:
pass
j = bf._req("GET", "/api/fs/job/status")
with LOCK:
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
current=j.get("current") or s)
if not j.get("busy"):
if j.get("error"):
raise E.PipeError(j["error"])
break
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP["items_done"] = len(srcs)
_fm_start(op, len(srcs), work)
def fm_delete(paths):
bf = fm_bf()
paths = [fm_clean(x) for x in paths]
for p in paths:
if p.count("/") < 2:
raise E.PipeError("Por seguridad no borro carpetas de primer nivel (%s)" % p)
def free_now():
try:
return int(bf._req("GET", "/api/fs/stat", {"path": paths[0].rsplit("/", 1)[0] or "/"},
timeout=8).get("availableBytes") or 0)
except Exception:
return None
def work():
start = free_now()
last = {"t": 0.0}
def tick():
# Borrar un juego grande tarda: ensenamos lo que se va liberando para que se vea que avanza
if start is None or time.time() - last["t"] < 3:
return
last["t"] = time.time()
now = free_now()
if now is not None:
with LOCK:
FMOP["bytes_done"] = max(0, now - start)
FMOP["message"] = ""
for i, p in enumerate(paths):
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP.update(current=p, items_done=i)
bf.delete(p, FM_CANCEL, on_tick=tick)
tick()
with LOCK:
FMOP["items_done"] = len(paths)
if FMOP["bytes_done"]:
FMOP["message"] = "Hecho: liberados %s" % human(FMOP["bytes_done"])
_fm_start("delete", len(paths), work)
def fm_download(paths):
cfg = cfg_snapshot()
paths = [fm_clean(x) for x in paths]
root_local = os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo")
def walk(rpath):
"""[(ruta remota, ruta relativa, tamano)] de un archivo o carpeta."""
out = []
base = posixpath.basename(rpath)
listing = None
try:
listing = fm_list(rpath)["entries"]
except Exception:
listing = None
if listing is None:
return [(rpath, base, None)]
stack = [(rpath, base, listing)]
while stack:
cur, rel, ents = stack.pop()
for e in ents:
rp, rr = cur + "/" + e["name"], rel + "/" + e["name"]
if e["dir"]:
stack.append((rp, rr, fm_list(rp)["entries"]))
else:
out.append((rp, rr, e.get("size")))
return out
def work():
files = []
for p in paths:
parent = fm_list(posixpath.dirname(p) or "/")["entries"]
ent = next((e for e in parent if e["name"] == posixpath.basename(p)), None)
if ent and not ent["dir"]:
files.append((p, posixpath.basename(p), ent.get("size")))
else:
files.extend(walk(p))
total = sum(sz or 0 for _, _, sz in files)
with LOCK:
FMOP.update(items_total=len(files), bytes_total=total, local=root_local)
done = 0
for i, (rp, rel, sz) in enumerate(files):
if FM_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
FMOP.update(current=rp, items_done=i)
local = os.path.join(root_local, *rel.split("/"))
if sz is not None and os.path.isfile(local) and os.path.getsize(local) == sz:
done += sz
continue
base = done
E.ftp_download(cfg, rp, local, FM_CANCEL, lambda n: FMOP.update(bytes_done=base + n), sz)
done = base + (sz or 0)
with LOCK:
FMOP.update(items_done=len(files), bytes_done=done,
message="Guardado en %s" % root_local)
_fm_start("download", len(paths), work)
def fm_chmod(paths):
bf = fm_bf()
paths = [fm_clean(x) for x in paths]
def work():
changed = 0
for i, p in enumerate(paths):
with LOCK:
FMOP.update(current=p, items_done=i)
c, _, _ = bf.fix_perms(p, FM_CANCEL)
changed += c
with LOCK:
FMOP.update(items_done=len(paths), message="Permisos 777 puestos en %d elementos" % changed)
_fm_start("chmod", len(paths), work)
PICK_SCRIPT = r'''
import json, sys, os
import tkinter as tk
from tkinter import filedialog
r = tk.Tk(); r.withdraw()
try:
r.attributes("-topmost", True)
except Exception:
pass
kind, title = sys.argv[1], sys.argv[2]
if kind == "folder":
p = filedialog.askdirectory(title=title, mustexist=True)
res = [p] if p else []
else:
res = list(filedialog.askopenfilenames(title=title))
print(json.dumps([os.path.normpath(x) for x in res]))
'''
# ---------------------------------------------------------------------------
# Pestana Espacio: que ocupa sitio en la consola (grafico de quesitos + lista)
# ---------------------------------------------------------------------------
SPACE = {"busy": False, "root": "", "done": 0, "total": 0, "current": "", "result": None, "error": ""}
SPACE_CANCEL = threading.Event()
IMAGE_RE = re.compile(r"\.(exfat|ffpkg|ffpfsc?|ufs|img)$", re.I)
EXPAND = ("homebrew", "pkg") # carpetas cuyo contenido se ensena elemento a elemento
def title_name(text):
"""Nombre del juego/app en un param.json (o None)."""
try:
j = json.loads(text)
except Exception:
m = re.search(r'"titleName"\s*:\s*"([^"]+)"', text or "")
return m.group(1) if m else None
lp = j.get("localizedParameters") or {}
for lang in (lp.get("defaultLanguage"), "es-ES", "en-US", "en-GB"):
if lang and isinstance(lp.get(lang), dict) and lp[lang].get("titleName"):
return lp[lang]["titleName"]
for v in lp.values():
if isinstance(v, dict) and v.get("titleName"):
return v["titleName"]
return j.get("titleName")
def space_scan(root):
cfg = cfg_snapshot()
bf = E.BFpilot(cfg)
if not bf.alive():
raise E.PipeError("Para analizar el espacio hace falta BFpilot cargado en la PS5")
root = fm_clean(root or "/data")
st = bf._req("GET", "/api/fs/stat", {"path": root})
total = int(st.get("usableTotalBytes") or st.get("totalBytes") or 0)
free = int(st.get("availableBytes") or 0)
work = [] # (ruta, nombre, es_carpeta, tamano_listado, grupo)
for e in bf.listdir(root):
path = "%s/%s" % (root.rstrip("/"), e["name"])
if e.get("dir") and e["name"].lower() in EXPAND:
for c in bf.listdir(path):
work.append(("%s/%s" % (path, c["name"]), c["name"], bool(c.get("dir")), c.get("size") or 0,
e["name"].lower()))
else:
work.append((path, e["name"], bool(e.get("dir")), e.get("size") or 0, ""))
extra = []
if root == "/data":
extra.append(("/user/app", "Juegos instalados desde la PS5", True, 0, "system"))
with LOCK:
SPACE.update(total=len(work) + len(extra), done=0)
items = []
for i, (path, name, is_dir, lsize, group) in enumerate(work + extra):
if SPACE_CANCEL.is_set():
raise E.Cancelled()
with LOCK:
SPACE.update(done=i, current=path)
size = bf.du(path, timeout=300) if is_dir else int(lsize or 0)
if size is None:
size = int(lsize or 0)
it = {"path": path, "name": name, "size": size, "dir": is_dir, "group": group,
"kind": "folder" if is_dir else "file", "title": None, "tid": None,
"deletable": True, "notes": []}
if group == "system":
it.update(kind="system", deletable=False)
it["notes"].append("Bórralos desde la consola: Ajustes > Almacenamiento")
elif group == "homebrew":
if is_dir and bf.exists(path + "/sce_sys/param.json"):
txt = bf.read_text(path + "/sce_sys/param.json")
it.update(kind="app", title=title_name(txt), tid=E.title_id(txt) or E.title_id(name))
elif IMAGE_RE.search(name):
it.update(kind="image", tid=E.title_id(name))
elif is_dir:
it["kind"] = "incomplete"
it["notes"].append("No tiene sce_sys: no es una app completa (¿envío cortado o copia a medias?)")
elif group == "pkg":
it["kind"] = "pkg"
it["notes"].append("Instalador .pkg: si ya está instalado, se puede borrar")
elif root == "/data" and name == "ps5directo":
it["kind"] = "leftover"
it["notes"].append("Restos de envíos de PS5 Directo")
elif root == "/data" and is_dir:
it["kind"] = "data"
it["notes"].append("Datos de payloads o ajustes: bórralo solo si sabes qué es")
if re.search(r"\.(ps5part|ava-part|part|tmp)$", name, re.I):
it["kind"] = "leftover"
it["notes"].append("Archivo a medias de un envío")
if is_dir and 0 < size < 1024 * 1024 and group == "homebrew":
it["notes"].append("Casi vacía (%s)" % human(size))
items.append(it)
# el mismo juego dos veces (por ejemplo la carpeta y la imagen convertida a lz4)
by_tid = {}
for it in items:
if it["tid"]:
by_tid.setdefault(it["tid"].upper(), []).append(it)
for tid, lst in by_tid.items():
if len(lst) > 1:
for it in lst:
others = [x["name"] for x in lst if x is not it]
it["dup"] = True
it["notes"].append("Duplicado: %s también está en %s" % (tid, ", ".join(others[:3])))
names = {it["tid"].upper(): it["title"] for it in items if it["tid"] and it["title"]}
for it in items:
if not it["title"] and it["tid"] and it["tid"].upper() in names:
it["title"] = names[it["tid"].upper()]
items.sort(key=lambda x: -x["size"])
used = max(0, total - free)
known = sum(x["size"] for x in items)
if extra and known > used: # /user/app no comparte particion: no lo contamos
items = [x for x in items if x["kind"] != "system"]
known = sum(x["size"] for x in items)
return {"root": root, "total": total, "free": free, "used": used,
"other": max(0, used - known), "items": items, "t": time.time()}
def space_start(root):
with LOCK:
if SPACE["busy"]:
raise E.PipeError("Ya se está analizando")
SPACE.update(busy=True, root=root, done=0, total=0, current="", error="")
SPACE_CANCEL.clear()
def run():
try:
res = space_scan(root)
with LOCK:
SPACE["result"] = res
except E.Cancelled:
with LOCK:
SPACE["error"] = "Cancelado"
except Exception as e:
traceback.print_exc()
with LOCK:
SPACE["error"] = str(e)
finally:
with LOCK:
SPACE["busy"] = False
threading.Thread(target=run, daemon=True).start()
def fm_api(p, b):
if p == "/api/space/scan":
space_start(str(b.get("root") or "/data"))
return {}
if p == "/api/space/state":
with LOCK:
return {"space": dict(SPACE)}
if p == "/api/space/cancel":
SPACE_CANCEL.set()
return {}
if p == "/api/fm/stat":
st = E.BFpilot(cfg_snapshot())._req("GET", "/api/fs/stat", {"path": fm_clean(b.get("path") or "/data")}, timeout=8)
return {"free": st.get("availableBytes"), "total": st.get("usableTotalBytes") or st.get("totalBytes")}
if p == "/api/fm/list":
return fm_list(b.get("path") or "/data")
if p == "/api/fm/du":
return {"size": fm_bf().du(fm_clean(b.get("path")))}
if p == "/api/fm/mkdir":
path = fm_clean(b.get("path"))
bf = E.BFpilot(cfg_snapshot())
if bf.alive():
bf.mkdir(path)
else:
E.Ftp(cfg_snapshot()).call(lambda f: f.mkd(path))
return {}
if p == "/api/fm/rename":
src, dst = fm_clean(b.get("src")), fm_clean(b.get("dst"))
bf = E.BFpilot(cfg_snapshot())
if bf.alive():
if bf.exists(dst):
raise E.PipeError("Ya existe %s" % dst)
bf.rename(src, dst)
else:
E.Ftp(cfg_snapshot()).call(lambda f: f.rename(src, dst))
return {}
if p == "/api/fm/paste":
op = b.get("op")
if op not in ("copy", "move"):
raise E.PipeError("operacion no valida")
fm_paste(op, b.get("srcs") or [], b.get("dst"), bool(b.get("overwrite")))
return {}
if p == "/api/fm/delete":
fm_delete(b.get("paths") or [])
return {}
if p == "/api/fm/download":
fm_download(b.get("paths") or [])
return {}
if p == "/api/fm/chmod":
fm_chmod(b.get("paths") or [])
return {}
if p == "/api/fm/op":
with LOCK:
return {"op": dict(FMOP)}
if p == "/api/fm/cancel":
FM_CANCEL.set()
return {}
if p == "/api/fm/open_local":
path = FMOP.get("local") or os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo")
if os.name == "nt" and os.path.isdir(path):
os.startfile(path)
return {"path": path}
if p == "/api/pick":
return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")}
if p == "/api/history":
return {"items": read_history()}
if p == "/api/history/clear":
_save_json(HISTORY_FILE, [])
return {}
raise E.PipeError("no existe")
def pick_paths(kind, title):
"""Abre el dialogo de Windows para elegir archivos o una carpeta del PC."""
import subprocess
exe = sys.executable
if os.name == "nt" and exe.lower().endswith("pythonw.exe"):
alt = exe[:-len("pythonw.exe")] + "python.exe"
exe = alt if os.path.isfile(alt) else exe
kw = {"creationflags": 0x08000000} if os.name == "nt" else {}
p = subprocess.run([exe, "-c", PICK_SCRIPT, kind, title or ""], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=3600, **kw)
if p.returncode != 0:
raise E.PipeError("No pude abrir el selector de archivos: %s"
% (p.stderr.decode("utf-8", "replace").strip().splitlines() or ["?"])[-1])
return json.loads(p.stdout.decode("utf-8", "replace").strip() or "[]")
# ---------------------------------------------------------------------------
# Carpetas del PC: se mandan tal cual, saltando lo que ya este en la consola
# ---------------------------------------------------------------------------
def folder_job(job, src, cancel):
cfg = cfg_snapshot()
cfg["_limit_bps"] = live_limit_bps
dest = (job.get("dest") or cfg["dest"]).rstrip("/")
tid = None
pj = os.path.join(src.path, "sce_sys", "param.json")
if os.path.isfile(pj):
try:
with open(pj, encoding="utf-8", errors="replace") as fh:
tid = E.title_id(fh.read())
except Exception:
pass
tid = tid or E.title_id(src.name)
name = (tid + "-app") if tid else src.name
target = dest + "/" + name
total = src.size
jlog(job, "Carpeta con %d archivos (%s) -> %s" % (len(src.files), human(total), target))
bf = E.BFpilot(cfg)
free = free_space(bf)
if free is not None:
jlog(job, "Espacio libre en la PS5: %s" % human(free))
if free < margin_bytes():
raise E.PipeError("No hay sitio en la PS5: quedan %s libres y el margen es %s. Libera espacio "
"y pulsa Reintentar." % (human(free), human(margin_bytes())))
if tid:
warn_duplicates(job, bf, tid, exclude=target)
up = Meter()
last = {"v": 0, "t": 0.0}
stop_speed = threading.Event()
def speed_loop():
while not stop_speed.wait(1):
setj(job, speed=up.rate(), dl_speed=0.0)
threading.Thread(target=speed_loop, daemon=True).start()
def progress(v, rel):
if v > last["v"]:
up(v - last["v"])
last["v"] = v
now = time.time()
if now - last["t"] > 0.5:
last["t"] = now
setj(job, sent=v, phase="Enviando carpeta: %s" % rel[-80:])
setj(job, status="enviando", total=total, sent=0, phase="Comparando con lo que ya hay en la PS5")
def plan(need):
jlog(job, "Falta por enviar: %s" % human(need))
if free is not None and need > 0 and free - need < margin_bytes():
raise E.PipeError("No cabe en la PS5: faltan %s por enviar, hay %s libres y el margen es %s. "
"Libera espacio y pulsa Reintentar." % (human(need), human(free), human(margin_bytes())))
watch = start_space_watch(job, bf, cancel, "el envio")
try:
sent, skipped, resumed = E.upload_tree(cfg, src, target, cancel, lambda m: jlog(job, m), progress,
on_plan=plan)
finally:
stop_speed.set()
watch.set()
setj(job, speed=0.0)
setj(job, sent=total)
jlog(job, "Carpeta enviada: %d archivos nuevos, %d ya estaban iguales, %d continuados"
% (sent, skipped, resumed))
if bf.alive():
fix_game_perms(job, bf, target, cancel)
return {"mode": "done", "placed": [target], "game": bool(tid)}
# ---------------------------------------------------------------------------
# Alternativa: descomprimir en el PC con 7-Zip y mandar el contenido ya suelto
# ---------------------------------------------------------------------------
@@ -605,11 +1257,12 @@ def pc_extract_job(job, srcs, cancel):
cfg = cfg_snapshot()
cfg["_limit_bps"] = live_limit_bps
log = lambda m: jlog(job, m) # noqa: E731
exe = E.find_7z(cfg.get("sevenzip_path"))
if not exe:
names = [s.name for s in srcs]
tar = any(E.is_tar(n) for n in names)
exe = None if tar else E.find_7z(cfg.get("sevenzip_path"))
if not tar and not exe:
raise E.PipeError("Para descomprimir en el PC hace falta 7-Zip (gratis, 7-zip.org). Instalalo, "
"o pon la ruta de 7z.exe en Ajustes, o elige 'Descomprimir en la PS5'.")
names = [s.name for s in srcs]
folder = os.path.join(pc_temp_root(cfg), E.safe_name(E.set_key(srcs[0].name)))
dl, up = Meter(), Meter()
phase = {"dl": True}
@@ -631,6 +1284,9 @@ def pc_extract_job(job, srcs, cancel):
speed_label="Descarga al PC", sent_label="Bajado al PC", pc_step="bajar")
local, created = fetch_to_pc(job, srcs, cfg, cancel, dl, folder)
first = local[E.first_volume(names)]
if tar:
phase["dl"] = False
return pc_tar_job(job, srcs, cfg, cancel, local[names[0]], created, folder, up)
# 2) Leer el indice (con contrasena si hace falta)
setj(job, phase="Leyendo el contenido del comprimido", speed_label="", sent_label="",
@@ -825,6 +1481,60 @@ def ftp_place(job, info, dest, cfg):
pass
def pc_tar_job(job, srcs, cfg, cancel, path, created, folder, up):
""".tar / .tar.gz / .tar.bz2 / .tar.xz: se descomprime en el PC con Python (sin 7-Zip)."""
staging = "%s/extract/%s" % (WORK_ROOT, job["id"])
total = os.path.getsize(path)
sig = "tar|%s|%d" % (os.path.basename(path), total)
st = _pcx_state(job)
skip = int(st.get("done") or 0) if st.get("sig") == sig else 0
bf = E.BFpilot(cfg)
if skip:
try:
if not bf.alive() or not bf.exists(staging):
skip = 0
except Exception:
skip = 0
free = free_space(bf)
if free is not None:
jlog(job, "Espacio libre en la PS5: %s" % human(free))
if free < margin_bytes():
raise E.PipeError("No hay sitio en la PS5: quedan %s libres y el margen es %s. Libera espacio "
"y pulsa Reintentar." % (human(free), human(margin_bytes())))
jlog(job, "Descomprimiendo el .tar en el PC y enviando a la PS5")
setj(job, status="enviando", total=total, sent=0, pc_step="enviar", sent_label="Leído del comprimido",
speed_label="Velocidad (comprimido)", phase="Descomprimiendo en el PC y enviando a la PS5")
last = {"v": 0, "t": 0.0}
def progress(v, tot, name):
if v > last["v"]:
up(v - last["v"])
last["v"] = v
now = time.time()
if now - last["t"] > 0.5:
last["t"] = now
setj(job, sent=v, phase="Descomprimiendo en el PC y enviando: %s" % name.rsplit("/", 1)[-1][-60:])
def on_file(n):
_pcx_save(job, {"sig": sig, "done": n})
watch = start_space_watch(job, bf, cancel, "el envio")
try:
nfiles, nbytes, params = E.stream_extract_tar(path, staging, cfg, cancel, lambda m: jlog(job, m),
progress, on_file, skip)
finally:
watch.set()
setj(job, sent=total)
jlog(job, "Enviado ya descomprimido: %d archivos, %s" % (nfiles, human(nbytes)))
games = []
for pj, raw in params.items():
root = pj[:-len("sce_sys/param.json")].rstrip("/")
if any(root == g["root"] or root.startswith(g["root"] + "/") for g in games if g["root"]):
continue
games.append({"root": root, "tid": E.title_id(raw.decode("utf-8", "replace")) or E.title_id(root)})
return {"mode": "pcx", "staging": staging, "hint": E.display_name(srcs[0].name), "games": games,
"pc_files": created, "pc_folder": folder if created else ""}
def finish_pc_extract(job, info, bf, dest, cancel, cfg):
staging = info["staging"]
setj(job, status="colocando", phase="Colocando en su sitio")
@@ -1056,6 +1766,11 @@ def _extract_job(job, info, cancel):
cfg = cfg_snapshot()
bf = E.BFpilot(cfg)
dest = job["dest"] or cfg["dest"]
if info["mode"] == "done":
for p in info.get("placed") or []:
jlog(job, "Colocado: %s" % p)
job["_placed_game"] = bool(info.get("game"))
return info.get("placed") or []
if info["mode"] == "pcx":
return finish_pc_extract(job, info, bf, dest, cancel, cfg)
wait_bfpilot(job, bf, cancel)
@@ -1271,7 +1986,7 @@ def transfer_worker():
idx = JOBS.index(job)
for k, extra in enumerate(sets[1:], 1):
nj = new_job([s.original for s in extra], job["password"], job["dest"],
where=job.get("where"))
where=job.get("where"), raw=job.get("raw"))
nj["sources"] = describe(extra)
nj["title"] = E.display_name(extra[0].name)
JOBS.insert(idx + k, nj)
@@ -1296,6 +2011,7 @@ def transfer_worker():
msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free)
setj(job, status="error", error=msg, phase="Error")
jlog(job, "ERROR: %s" % msg)
record_history(job)
traceback.print_exc()
save_jobs()
@@ -1318,6 +2034,7 @@ def extract_worker():
phase=("Listo. ShadowMountPlus lo anadira al inicio en unos segundos" if is_game
else "Listo. Copiado en la PS5"))
jlog(job, "Terminado")
record_history(job)
except E.Cancelled:
stopped(job)
except Exception as e:
@@ -1328,6 +2045,7 @@ def extract_worker():
msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free)
setj(job, status="error", error=msg, phase="Error")
jlog(job, "ERROR: %s" % msg)
record_history(job)
traceback.print_exc()
save_jobs()
@@ -1522,8 +2240,10 @@ def run_speedtest(url, seconds=10):
ideal = 1 / (1 / dl + 1 / up)
pipe = min(both, res["both_dl"]) # lo que llegaria de verdad descargando y enviando
res["pipe"] = pipe
v.append("Las dos cosas a la vez: en la practica llegarian unos %.1f MB/s a la PS5. Con una "
"sola radio Wi-Fi lo maximo esperable seria unos %.1f MB/s." % (pipe / mb, ideal / mb))
v.append("Las dos cosas a la vez: en la practica llegarian unos %.1f MB/s a la PS5." % (pipe / mb))
if dl >= 5 * mb:
# solo tiene sentido cuando el enlace no es el cuello de botella
v.append("Con una sola radio Wi-Fi lo maximo esperable seria unos %.1f MB/s." % (ideal / mb))
if dl < 5 * mb:
v.append("Aqui manda el alojamiento: mientras de tan poca velocidad, nada de tu lado "
"lo va a mejorar.")
@@ -1622,6 +2342,90 @@ def _ver_tuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v or "0")[:4])
def _changelog(lang):
"""{version: [novedades]} sacado de LEEME.txt (es) o README_EN.txt (en)."""
fn = "README_EN.txt" if lang == "en" else "LEEME.txt"
try:
with open(os.path.join(HERE, fn), encoding="utf-8") as f:
lines = f.read().splitlines()
except Exception:
return {}
out, cur, item = {}, None, None
in_en = False
for ln in lines:
if lang == "en":
if ln.startswith("WHAT'S NEW"):
in_en = True
continue
if not in_en:
continue
if ln.strip() == "" and cur:
break
m = re.match(r"^(\d+\.\d+(?:\.\d+)?)\s+(.*)$", ln)
if m:
cur = m.group(1)
out[cur] = []
rest = m.group(2).strip()
item = None
ln = " " + rest
else:
m = re.match(r"^NOVEDADES (\d+\.\d+(?:\.\d+)?)\s*$", ln)
if m:
cur = m.group(1)
out[cur] = []
item = None
continue
if cur and ln.strip() == "":
cur = None
continue
if not cur:
continue
t = ln.strip()
if t.startswith("- "):
out[cur].append(t[2:])
elif t and out[cur]:
out[cur][-1] += " " + t
elif t:
out[cur].append(t)
return out
def whats_new(lang):
"""Novedades que ensenar tras actualizar (vacio si no toca)."""
with LOCK:
seen = SETTINGS.get("seen_version") or ""
tour_done = SETTINGS.get("tour_done")
cur = _ver_tuple(VERSION)
if seen and _ver_tuple(seen) >= cur:
return {"show": False}
if not seen:
if not tour_done: # instalacion nueva: ya ve la guia de bienvenida
with LOCK:
SETTINGS["seen_version"] = VERSION
save_settings()
return {"show": False}
# venia de una version sin este aviso: la anterior es la copia en .backup
prev = None
try:
for d in os.listdir(os.path.join(HERE, ".backup")):
if re.match(r"^\d+(\.\d+)+$", d) and _ver_tuple(d) < cur:
if prev is None or _ver_tuple(d) > _ver_tuple(prev):
prev = d
except Exception:
pass
seen = prev
log = _changelog(lang)
pad = lambda t: (t + (0, 0, 0))[:3]
entries = []
for v, items in log.items():
vt = pad(_ver_tuple(v))
if vt <= pad(cur) and (seen is None and vt[:2] == pad(cur)[:2] or seen is not None and vt > pad(_ver_tuple(seen))):
if items:
entries.append({"version": v, "items": items})
entries.sort(key=lambda e: _ver_tuple(e["version"]), reverse=True)
return {"show": bool(entries), "version": VERSION, "entries": entries[:6]}
def install_zip(data):
"""Instala una version nueva desde los bytes de un .zip. Devuelve la version instalada."""
import io
@@ -1796,6 +2600,14 @@ class Handler(BaseHTTPRequestHandler):
url = str(b.get("url") or "").strip()
if not re.match(r"^https?://", url, re.I) or len(url) > 4000:
return self._send(400, {"ok": False, "error": "enlace no valido"})
# cookies y navegador que usa ese alojamiento: el servidor nos dara la misma
# velocidad y el mismo permiso que al navegador (sesion, cuenta premium)
cookies = b.get("cookies") if isinstance(b.get("cookies"), dict) else {}
for u in (url, str(b.get("original") or "")):
if re.match(r"^https?://", u, re.I):
host = urllib.parse.urlparse(u).netloc.lower()
E.set_browser_headers(u, str(cookies.get(host) or ""), str(b.get("ua") or ""),
str(b.get("referrer") or ""))
with LOCK:
if not any(x["url"] == url for x in INBOX):
INBOX.append({"url": url, "name": str(b.get("name") or "")[:300], "t": time.time()})
@@ -1846,6 +2658,13 @@ class Handler(BaseHTTPRequestHandler):
return self._send(400, {"ok": False, "error": str(e)})
restart_soon()
return self._send(200, {"ok": True, "version": ver})
if p == "/api/whatsnew":
return self._send(200, dict(ok=True, **whats_new("en" if b.get("lang") == "en" else "es")))
if p == "/api/whatsnew/seen":
with LOCK:
SETTINGS["seen_version"] = VERSION
save_settings()
return self._send(200, {"ok": True})
if p == "/api/settings":
with LOCK:
for k, v in b.items():
@@ -1859,6 +2678,13 @@ class Handler(BaseHTTPRequestHandler):
continue
elif isinstance(DEFAULTS[k], list):
v = [x.strip() for x in (v or []) if str(x).strip()]
if k == "ps5_ip":
# "ftp://192.168.1.50:2121", con espacios o comas: dejar solo la IP
v, port = E.clean_host(v)
if port and port != int(b.get("bf_port") or SETTINGS.get("bf_port", 5905)):
SETTINGS["ftp_port"] = port
if "ftp_port" in b:
b["ftp_port"] = port
SETTINGS[k] = v
save_settings()
return self._send(200, {"ok": True})
@@ -1874,6 +2700,14 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
bases = ["/data/homebrew"]
return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]})
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p in ("/api/pick", "/api/history", "/api/history/clear"):
try:
return self._send(200, dict(ok=True, **fm_api(p, b)))
except E.PipeError as e:
return self._send(200, {"ok": False, "error": str(e)})
except Exception as e:
traceback.print_exc()
return self._send(200, {"ok": False, "error": str(e)})
if p == "/api/fixperms":
try:
return self._send(200, {"ok": True, "results": fix_all_perms()})
@@ -1913,7 +2747,8 @@ class Handler(BaseHTTPRequestHandler):
dest = (b.get("dest") or SETTINGS["dest"]).rstrip("/") or "/data/homebrew"
if not dest.startswith("/data") and not dest.startswith("/mnt/"):
return self._send(400, {"ok": False, "error": "El destino debe estar en /data o /mnt"})
job = new_job(lines, (b.get("password") or "").strip(), dest, where=b.get("where"))
job = new_job(lines, (b.get("password") or "").strip(), dest, where=b.get("where"),
raw=bool(b.get("raw")))
with WAKE:
JOBS.append(job)
WAKE.notify_all()
@@ -2067,10 +2902,8 @@ def dropped_paths():
if a.startswith("--"):
continue
a = a.strip().strip('"')
if os.path.isfile(a):
if os.path.isfile(a) or os.path.isdir(a):
out.append(os.path.abspath(a))
elif os.path.isdir(a):
print("Es una carpeta, la salto (arrastra archivos): %s" % a)
return out
+84
View File
@@ -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);
});
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.backup/
*.nuevo
+557 -24
View File
@@ -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,309 @@ 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)
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):
out = {}
try:
for name, facts in f.mlsd(path, facts=["type", "size"]):
if facts.get("type") == "file":
out[name] = int(facts.get("size") or 0)
return out
except ftplib.error_perm as e:
if str(e).startswith("550"):
return None
except Exception:
pass
# servidor sin MLSD: LIST y leer el tamano de cada linea
lines = []
try:
f.retrlines("LIST " + path, lines.append)
except ftplib.error_perm:
return None
for ln in lines:
parts = ln.split(None, 8)
if len(parts) == 9 and not ln.startswith("d"):
try:
out[parts[8]] = int(parts[4])
except ValueError:
pass
return out
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 +952,24 @@ 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
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,7 +979,11 @@ 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")
@@ -679,7 +1134,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 +1257,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 +1275,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 +1287,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 +1507,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 +1691,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
+17 -3
View File
@@ -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.")
});
}
+29 -8
View File
@@ -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"
}
}
+2 -2
View File
@@ -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>
+19 -3
View File
@@ -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}`;
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+1194
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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

+32
View File
@@ -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"
}
}
+20
View File
@@ -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>
+42
View File
@@ -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}`;
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+575 -13
View File
@@ -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&#10;https://servidor/archivo.part2.rar&#10;&#10;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&#10;https://servidor/archivo.part2.rar&#10;&#10;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>