Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e642e0986 | ||
|
|
d8113534ea | ||
|
|
5128f49f8b | ||
|
|
c8a0466854 | ||
|
|
3314a0f1b0 | ||
|
|
baa4616faf | ||
|
|
d9c33091ca |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
PS5 DIRECTO 1.16
|
||||
PS5 DIRECTO 1.19
|
||||
================
|
||||
(English: see README_EN.txt)
|
||||
|
||||
@@ -24,8 +24,21 @@ PESTAÑAS
|
||||
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.
|
||||
Clic derecho en un archivo o en el hueco para el menú; atajos: Supr,
|
||||
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A y Retroceso. "Mover a…" y "Copiar
|
||||
a…" abren un selector de carpetas con los destinos recientes. Si al pegar
|
||||
ya existe algo con el mismo nombre, eliges reemplazar/combinar, conservar
|
||||
ambos u omitir. "Extraer…" descomprime en la consola un .rar/.7z/.zip que
|
||||
ya esté allí. A la izquierda tienes las unidades (con su espacio libre) y
|
||||
accesos rápidos. Puedes arrastrar archivos del PC a la carpeta abierta.
|
||||
Sin BFpilot casi todo funciona por FTP (copiar pasa por el PC, más
|
||||
lento); descomprimir y ver el espacio libre sí necesitan BFpilot.
|
||||
- Espacio: gráfico de quesitos con lo que ocupa sitio en la consola, con
|
||||
el nombre de cada 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
|
||||
@@ -162,6 +175,68 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
|
||||
Ajustes.
|
||||
|
||||
|
||||
NOVEDADES 1.19
|
||||
- Explorador de archivos renovado: menú de clic derecho, atajos de teclado,
|
||||
ruta por tramos, unidades y accesos a la izquierda, "Mover a…" / "Copiar
|
||||
a…" con destinos recientes, aviso al pegar si ya existe (reemplazar,
|
||||
combinar, conservar ambos u omitir), "Extraer…" de comprimidos que ya
|
||||
están en la consola, y arrastrar archivos del PC a la carpeta abierta.
|
||||
- El explorador ya no necesita BFpilot para casi nada: renombrar, mover,
|
||||
borrar, crear carpetas, permisos, tamaños y copiar funcionan solo con FTP.
|
||||
- Campana de notificaciones arriba: transferencias terminadas o con error,
|
||||
operaciones de archivos, desconexiones de la consola y versiones nuevas.
|
||||
|
||||
NOVEDADES 1.18.4
|
||||
- Arreglado: si dejabas la ventana minimizada o en segundo plano un rato, el
|
||||
programa se cerraba solo y al volver salía "Failed to fetch". Ahora solo se
|
||||
cierra cuando cierras la ventana de verdad.
|
||||
|
||||
NOVEDADES 1.18.3
|
||||
- Arreglado "Failed to fetch" y la cola que aparecía y desaparecía: en Windows
|
||||
podían quedar dos PS5 Directo escuchando en el mismo puerto (por ejemplo al
|
||||
abrirlo dos veces seguidas). Ahora solo puede haber uno abierto.
|
||||
- Si la conexión con el programa falla un instante, se reintenta sola.
|
||||
|
||||
NOVEDADES 1.18.2
|
||||
- Crea automáticamente un acceso directo "PS5 Directo" en el escritorio (y en "Enviar a")
|
||||
con el icono del programa. Si lo borras no vuelve a aparecer; si apuntaba a
|
||||
una copia vieja del programa, se corrige.
|
||||
- La barra de tareas enseña el icono nuevo mientras el programa está abierto.
|
||||
- Si se había quedado abierta una versión antigua (por ejemplo una copia vieja
|
||||
en otra carpeta), al abrir esta se cierra la vieja sola en vez de enseñarla.
|
||||
|
||||
NOVEDADES 1.18.1
|
||||
- Al borrar algo grande se ve cuánto se ha liberado y el tiempo que lleva, y el
|
||||
espacio libre del explorador se actualiza mientras tanto.
|
||||
|
||||
NOVEDADES 1.18
|
||||
- Pestaña "Espacio": gráfico de quesitos y lista de todo lo que ocupa sitio en
|
||||
la consola, con el nombre de cada 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í.
|
||||
@@ -278,7 +353,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.
|
||||
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
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.
|
||||
|
||||
- ✅ Streams downloads straight to the console: nothing is saved on the PC
|
||||
- ✅ Joins split parts (.part1, .part2… / .001, .002…) and extracts RAR, 7z and ZIP
|
||||
- ✅ 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
|
||||
- ✅ Send whole PC folders, skipping what's already there and resuming what got cut off
|
||||
- ✅ Built-in console file browser (works over plain FTP, BFpilot optional): right-click menu, shortcuts, move/copy to…, extract archives on the console, drag & drop uploads, download to PC
|
||||
- ✅ Notification bell: finished or failed transfers, disconnections and new versions
|
||||
- ✅ Storage view: a pie chart of what fills the console, by 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
|
||||
- ✅ Optional browser extension: links arrive by themselves, no copy and paste
|
||||
- ✅ 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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
PS5 DIRECTO 1.16 — USER GUIDE (English)
|
||||
=======================================
|
||||
PS5 DIRECTO 1.19 — USER GUIDE (English)
|
||||
=========================================
|
||||
|
||||
Paste links (or drag files from your PC) and the tool does the rest:
|
||||
|
||||
@@ -23,8 +23,20 @@ TABS
|
||||
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.
|
||||
Right-click a file or the empty space for the menu; shortcuts: Del,
|
||||
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A and Backspace. "Move to…" and
|
||||
"Copy to…" open a folder picker with recent destinations. If something
|
||||
with the same name already exists when pasting, you choose replace/merge,
|
||||
keep both or skip. "Extract…" unpacks a .rar/.7z/.zip that is already on
|
||||
the console. Drives (with free space) and quick places are on the left.
|
||||
You can drag files from the PC onto the open folder.
|
||||
Without BFpilot almost everything works over FTP (copying goes through
|
||||
the PC, slower); extracting and showing free space do need BFpilot.
|
||||
- Space: a pie chart of what takes up space on the console, with each
|
||||
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
|
||||
@@ -201,7 +213,8 @@ IF SOMETHING GOES WRONG
|
||||
|
||||
LINKS
|
||||
-----
|
||||
Direct download links work. Pixeldrain, Dropbox and some Mediafire and
|
||||
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.
|
||||
@@ -214,6 +227,50 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo
|
||||
|
||||
WHAT'S NEW
|
||||
----------
|
||||
1.19 - Renewed file browser: right-click menu, keyboard shortcuts,
|
||||
clickable path, drives and places on the left, "Move to…" / "Copy
|
||||
to…" with recent destinations, a choice when pasting over existing
|
||||
items (replace, merge, keep both or skip), "Extract…" for archives
|
||||
already on the console, and dragging files from the PC onto the
|
||||
open folder.
|
||||
- The file browser no longer needs BFpilot for most things: rename,
|
||||
move, delete, new folder, permissions, sizes and copy work over FTP.
|
||||
- Notification bell at the top: finished or failed transfers, file
|
||||
operations, console disconnections and new versions.
|
||||
1.18.4 - Fixed: leaving the window minimized or in the background for a while
|
||||
made the program close itself, showing "Failed to fetch" on return.
|
||||
Now it only closes when you actually close the window.
|
||||
1.18.3 - Fixed "Failed to fetch" and the queue appearing and disappearing: on
|
||||
Windows two PS5 Directo could end up listening on the same port (e.g.
|
||||
when opened twice in a row). Now only one can be open. Brief
|
||||
connection hiccups are retried automatically.
|
||||
1.18.2 - Creates a "PS5 Directo" shortcut on the desktop (and in "Send to")
|
||||
with the program's icon. If you delete it, it won't come back; if it
|
||||
pointed to an old copy, it's fixed. The taskbar shows the new icon.
|
||||
If an old version was still open (e.g. an old copy in another folder),
|
||||
it's closed automatically instead of being shown.
|
||||
1.18.1 - When deleting something big you see how much has been freed and how
|
||||
long it has taken, and the browser's free space updates meanwhile.
|
||||
1.18 - "Space" tab: a pie chart and list of everything that takes up space
|
||||
on the console, by 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -529,10 +669,94 @@ class FolderSource:
|
||||
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)
|
||||
@@ -577,16 +801,23 @@ def _ftp_sizes(f, path):
|
||||
return out
|
||||
|
||||
|
||||
def upload_tree(cfg, src, remote_root, cancel, log, progress):
|
||||
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.
|
||||
progress(bytes_hechos, ruta_actual). Devuelve (enviados, saltados, continuados)."""
|
||||
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))
|
||||
done = 0
|
||||
sent = skipped = resumed = 0
|
||||
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)
|
||||
@@ -597,40 +828,74 @@ def upload_tree(cfg, src, remote_root, cancel, log, progress):
|
||||
w.mkdir(rdir)
|
||||
have = w._retry(lambda: ftp_sizes(w.f, rdir)) or {}
|
||||
for name, size, rel in by_dir[d]:
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
rpath = rdir + "/" + name
|
||||
got = have.get(name)
|
||||
if got == size:
|
||||
skipped += 1
|
||||
done += size
|
||||
progress(done, rel)
|
||||
st["skipped"] += 1
|
||||
st["done"] += size
|
||||
continue
|
||||
offset = got if (got is not None and 0 < got < size) else 0
|
||||
if offset:
|
||||
resumed += 1
|
||||
log("Continuando %s desde %s MB" % (rel, offset // MiB))
|
||||
local = os.path.join(src.path, *rel.split("/"))
|
||||
base = done
|
||||
w.begin(rpath, offset)
|
||||
with open(local, "rb") as fh:
|
||||
fh.seek(offset)
|
||||
pos = offset
|
||||
while True:
|
||||
if cancel.is_set():
|
||||
raise Cancelled()
|
||||
chunk = fh.read(4 * MiB)
|
||||
if not chunk:
|
||||
break
|
||||
w.write(chunk)
|
||||
pos += len(chunk)
|
||||
progress(base + pos, rel)
|
||||
w.end(size)
|
||||
done = base + size
|
||||
sent += 1
|
||||
return sent, skipped, resumed
|
||||
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):
|
||||
@@ -687,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 ""
|
||||
@@ -699,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")
|
||||
@@ -850,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):
|
||||
@@ -973,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"):
|
||||
@@ -986,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"})
|
||||
@@ -998,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
|
||||
|
||||
@@ -1218,7 +1507,7 @@ 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
|
||||
@@ -1402,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
|
||||
@@ -44,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; }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3a7bff"/>
|
||||
<stop offset=".55" stop-color="#4b4fe6"/>
|
||||
<stop offset="1" stop-color="#7b3fe4"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx=".3" cy=".2" r=".9">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".35"/>
|
||||
<stop offset=".5" stop-color="#fff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="arrow" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dfe6ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="240" height="240" rx="58" fill="url(#bg)"/>
|
||||
<rect x="8" y="8" width="240" height="240" rx="58" fill="url(#glow)"/>
|
||||
<rect x="9.5" y="9.5" width="237" height="237" rx="56.5" fill="none" stroke="#fff" stroke-opacity=".22" stroke-width="3"/>
|
||||
<!-- estelas de velocidad -->
|
||||
<g stroke="#fff" stroke-linecap="round" stroke-opacity=".45" stroke-width="9">
|
||||
<line x1="62" y1="58" x2="62" y2="92"/>
|
||||
<line x1="194" y1="58" x2="194" y2="92"/>
|
||||
</g>
|
||||
<!-- flecha -->
|
||||
<path d="M128 44 c9 0 15 6 15 15 v58 h22 c11 0 16 13 8 21 l-35 37 c-6 6-14 6-20 0 l-35-37 c-8-8-3-21 8-21 h22 v-58 c0-9 6-15 15-15z" fill="url(#arrow)"/>
|
||||
<!-- bandeja / consola -->
|
||||
<path d="M52 168 v18 c0 14 10 24 24 24 h104 c14 0 24-10 24-24 v-18" fill="none" stroke="#fff" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="176" cy="190" r="5.5" fill="#7dffb2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "PS5 Directo - Capturar enlaces",
|
||||
"version": "1.1.0",
|
||||
"description": "Cuando descargas un .rar/.7z/.zip/.pkg, manda el enlace (con la sesion del navegador) a PS5 Directo en vez de bajarlo al PC.",
|
||||
"permissions": [
|
||||
"downloads",
|
||||
"storage",
|
||||
"notifications",
|
||||
"cookies"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "bg.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "PS5 Directo",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"32": "icon32.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="es"><head><meta charset="utf-8">
|
||||
<style>
|
||||
body{font:14px system-ui,sans-serif;background:#0d1117;color:#e6edf3;margin:0;padding:14px;width:280px}
|
||||
h1{font-size:15px;margin:0 0 10px}
|
||||
.row{display:flex;align-items:center;justify-content:space-between;margin:8px 0}
|
||||
.st{font-size:13px;opacity:.85}
|
||||
.ok{color:#4ade80}.bad{color:#f87171}
|
||||
small{opacity:.6;display:block;margin-top:10px;line-height:1.4}
|
||||
button{background:#1f2937;color:#e6edf3;border:1px solid #374151;border-radius:6px;padding:4px 10px;cursor:pointer}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>PS5 Directo</h1>
|
||||
<div class="row"><label for="en" id="lblEn">Capturar enlaces</label><input type="checkbox" id="en"></div>
|
||||
<div class="st" id="st">Comprobando…</div>
|
||||
<div class="st" id="cnt"></div>
|
||||
<div class="row"><span></span><button id="reset">Poner contador a 0</button></div>
|
||||
<small id="help">Pasa el captcha y pulsa Descargar como siempre: si es un .rar, .7z, .zip, .pkg… y PS5 Directo está abierto, el enlace va a su cuadro de enlaces y el navegador no descarga nada. Si PS5 Directo está cerrado, la descarga sigue normal.</small>
|
||||
<script src="popup.js"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,42 @@
|
||||
const EN = !(navigator.language || "es").toLowerCase().startsWith("es");
|
||||
const T = EN ? {
|
||||
capture: "Capture links", checking: "Checking…", reset: "Reset counter",
|
||||
help: "Pass the captcha and click Download as usual: if it's a .rar, .7z, .zip, .pkg… and PS5 Directo is open, the link goes to its link box and the browser downloads nothing. If PS5 Directo is closed, the download goes ahead normally.",
|
||||
sent: "Sent", last: "last", connected: "● Connected", to: "to PS5 Directo", closed: "● PS5 Directo is not open", normal: "(downloads will work normally)"
|
||||
} : {
|
||||
capture: "Capturar enlaces", checking: "Comprobando…", reset: "Poner contador a 0",
|
||||
help: null, sent: "Enviados", last: "último", connected: "● Conectado", to: "a PS5 Directo", closed: "● PS5 Directo no está abierto", normal: "(las descargas seguirán normales)"
|
||||
};
|
||||
if (EN) {
|
||||
document.documentElement.lang = "en";
|
||||
document.getElementById("lblEn").textContent = T.capture;
|
||||
document.getElementById("st").textContent = T.checking;
|
||||
document.getElementById("reset").textContent = T.reset;
|
||||
document.getElementById("help").textContent = T.help;
|
||||
}
|
||||
async function findApp() {
|
||||
for (const port of [8777, 8778, 8779]) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/ext/ping`, { cache: "no-store" });
|
||||
if (r.ok) { const j = await r.json(); if (j.app === "PS5 Directo") return j; }
|
||||
} catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
(async () => {
|
||||
const cfg = await chrome.storage.local.get({ enabled: true, captured: 0, last: "" });
|
||||
const en = document.getElementById("en");
|
||||
en.checked = cfg.enabled;
|
||||
en.onchange = () => chrome.storage.local.set({ enabled: en.checked });
|
||||
document.getElementById("cnt").textContent =
|
||||
cfg.captured ? `${T.sent}: ${cfg.captured}${cfg.last ? " (" + T.last + ": " + cfg.last + ")" : ""}` : "";
|
||||
document.getElementById("reset").onclick = async () => {
|
||||
await chrome.storage.local.set({ captured: 0, last: "" });
|
||||
chrome.action.setBadgeText({ text: "" });
|
||||
document.getElementById("cnt").textContent = "";
|
||||
};
|
||||
const app = await findApp();
|
||||
const st = document.getElementById("st");
|
||||
if (app) { st.innerHTML = `<span class="ok">${T.connected}</span> ${T.to} v${app.version}`; chrome.action.setBadgeText({ text: "" }); }
|
||||
else st.innerHTML = `<span class="bad">${T.closed}</span> ${T.normal}`;
|
||||
})();
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -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}
|
||||
@@ -69,6 +69,8 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
.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}
|
||||
@@ -76,6 +78,7 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
.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}
|
||||
@@ -102,6 +105,37 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto}
|
||||
.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}
|
||||
@@ -151,6 +185,7 @@ tr:hover .rowact{opacity:1}
|
||||
<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>
|
||||
|
||||
@@ -158,7 +193,7 @@ tr:hover .rowact{opacity:1}
|
||||
<section class="card">
|
||||
<h2>Nueva transferencia</h2>
|
||||
<label for="lines">Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…</label>
|
||||
<textarea id="lines" placeholder="https://servidor/archivo.part1.rar https://servidor/archivo.part2.rar También vale la ruta de un archivo de tu PC, por ejemplo: D:\Descargas\archivo.rar"></textarea>
|
||||
<textarea id="lines" placeholder="https://servidor/archivo.part1.rar https://servidor/archivo.part2.rar También vale la ruta de un archivo o carpeta de tu PC (D:\Descargas\archivo.rar) o un enlace ftp:// de tu NAS"></textarea>
|
||||
<div class="drophint" id="drophint">Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el <b>icono de PS5 Directo</b>, o clic derecho → <b>Enviar a → PS5 Directo</b>.</div>
|
||||
<div class="pickrow">
|
||||
<button class="small" id="btnPickFiles">Elegir archivos…</button>
|
||||
@@ -245,6 +280,24 @@ tr:hover .rowact{opacity:1}
|
||||
</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>
|
||||
@@ -254,6 +307,12 @@ tr:hover .rowact{opacity:1}
|
||||
</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">
|
||||
@@ -344,8 +403,8 @@ tr:hover .rowact{opacity:1}
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
const I18N_EXACT={"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 copied.", "Contraseña del comprimido (vacío = probar las guardadas):": "Archive password (empty = try the saved ones):", "¿Cancelar esta transferencia? Lo ya enviado se conserva y se puede reintentar.": "Cancel this transfer? What was already sent is kept and you can retry.", "Añadido a la cola": "Added to queue", "pendiente": "pending", "comprobando": "checking", "enviando": "sending", "en cola para descomprimir": "waiting to extract", "extrayendo": "extracting", "colocando": "placing", "hecho": "done", "error": "error", "cancelado": "cancelled", "pausado": "paused", "Mide por separado la descarga desde el enlace, el envío del PC a la PS5 y las dos cosas a la vez (unos 30 segundos). Así se sabe de quién es la culpa cuando va lento. No se guarda nada: el archivo de prueba de la PS5 se borra al acabar.": "Measures separately the download from the link, the upload from PC to PS5, and both at once (about 30 seconds), so you know what's to blame when it's slow. Nothing is kept: the test file on the PS5 is deleted at the end.", "Cerrar": "Close", "Midiendo…": "Measuring…", "Descarga sola": "Download only", "PC → PS5 solo": "PC → PS5 only", "Las dos a la vez: descarga / envío": "Both at once: download / upload", "Llegaría a la PS5 descargando y enviando": "Would reach the PS5 downloading and sending", "IP de la PS5": "PS5 IP address", "por ejemplo 192.168.1.50": "for example 192.168.1.50", "Destino por defecto": "Default destination", "Puerto FTP": "FTP port", "Usuario FTP": "FTP user", "Contraseña FTP": "FTP password", "(vacía)": "(empty)", "Puerto BFpilot": "BFpilot port", "Conexiones por archivo": "Connections per file", "Partes a la vez (1-4)": "Parts at once (1-4)", "Trozo (MB)": "Line 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."], ["^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}."], ["^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 in, click \"Add to queue\"."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo enviado esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was sent is in {1}"], ["^Lo usa \"([\\s\\S]*?)\" de la cola: si lo borras, esa transferencia tendrá que enviarse otra vez\\.$", "Used by \"{0}\" in the queue: if you delete it, that transfer will have to be sent again."], ["^No encuentro en la PS5 lo enviado \\(([\\s\\S]*?)\\)\\. Pulsa ReintentaLine truncated
|
||||
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'})();
|
||||
@@ -429,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);
|
||||
@@ -466,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>
|
||||
@@ -551,7 +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. Actividad guarda el historial.'},
|
||||
{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;
|
||||
@@ -611,7 +676,7 @@ $('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);
|
||||
@@ -797,11 +862,12 @@ function afterUpdate(v){
|
||||
let curTab='Transfers';
|
||||
function showTab(t){
|
||||
curTab=t;
|
||||
['Transfers','Files','Activity'].forEach(x=>{$('tab'+x).hidden=(x!==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));
|
||||
|
||||
@@ -816,6 +882,152 @@ async function pick(kind){
|
||||
$('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>';
|
||||
@@ -931,16 +1143,17 @@ async function fmOpStart(url,body){
|
||||
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;return}
|
||||
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)}</span>
|
||||
<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 {
|
||||
|
||||