mirror of
https://github.com/jstrdev/ps5directo.git
synced 2026-10-29 04:01:51 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088c46f548 | ||
|
|
b65a3b34dd | ||
|
|
3e642e0986 | ||
|
|
d8113534ea | ||
|
|
5128f49f8b | ||
|
|
c8a0466854 | ||
|
|
3314a0f1b0 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
PS5 DIRECTO 1.18
|
||||
================
|
||||
PS5 DIRECTO 1.19.1
|
||||
==================
|
||||
(English: see README_EN.txt)
|
||||
|
||||
Pega los enlaces (o arrastra archivos de tu PC) y la herramienta hace todo sola:
|
||||
@@ -24,8 +24,15 @@ 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
|
||||
@@ -168,6 +175,50 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
|
||||
Ajustes.
|
||||
|
||||
|
||||
NOVEDADES 1.19.1
|
||||
- Arreglado: la cola dejaba de actualizarse sola (desde la 1.18.3), así que
|
||||
"Quitar" parecía no hacer nada y luego decía "no existe".
|
||||
- El explorador vuelve a listar la carpeta abierta cuando termina una subida
|
||||
a esa carpeta.
|
||||
- Arreglado "Descargar al PC" con juegos: ftpsrv descifraba al vuelo los
|
||||
eboot.bin/.prx/.sprx y la descarga salía incompleta. Ahora se transfieren
|
||||
tal cual están (también mejora comparar tamaños al reanudar carpetas).
|
||||
Gracias a RichyMX por encontrarlo y proponer el arreglo.
|
||||
|
||||
NOVEDADES 1.19
|
||||
- Explorador de archivos renovado: menú de clic derecho, atajos de teclado,
|
||||
ruta por tramos, unidades y accesos a la izquierda, "Mover a…" / "Copiar
|
||||
a…" con destinos recientes, aviso al pegar si ya existe (reemplazar,
|
||||
combinar, conservar ambos u omitir), "Extraer…" de comprimidos que ya
|
||||
están en la consola, y arrastrar archivos del PC a la carpeta abierta.
|
||||
- El explorador ya no necesita BFpilot para casi nada: renombrar, mover,
|
||||
borrar, crear carpetas, permisos, tamaños y copiar funcionan solo con FTP.
|
||||
- Campana de notificaciones arriba: transferencias terminadas o con error,
|
||||
operaciones de archivos, desconexiones de la consola y versiones nuevas.
|
||||
|
||||
NOVEDADES 1.18.4
|
||||
- Arreglado: si dejabas la ventana minimizada o en segundo plano un rato, el
|
||||
programa se cerraba solo y al volver salía "Failed to fetch". Ahora solo se
|
||||
cierra cuando cierras la ventana de verdad.
|
||||
|
||||
NOVEDADES 1.18.3
|
||||
- Arreglado "Failed to fetch" y la cola que aparecía y desaparecía: en Windows
|
||||
podían quedar dos PS5 Directo escuchando en el mismo puerto (por ejemplo al
|
||||
abrirlo dos veces seguidas). Ahora solo puede haber uno abierto.
|
||||
- Si la conexión con el programa falla un instante, se reintenta sola.
|
||||
|
||||
NOVEDADES 1.18.2
|
||||
- Crea automáticamente un acceso directo "PS5 Directo" en el escritorio (y en "Enviar a")
|
||||
con el icono del programa. Si lo borras no vuelve a aparecer; si apuntaba a
|
||||
una copia vieja del programa, se corrige.
|
||||
- La barra de tareas enseña el icono nuevo mientras el programa está abierto.
|
||||
- Si se había quedado abierta una versión antigua (por ejemplo una copia vieja
|
||||
en otra carpeta), al abrir esta se cierra la vieja sola en vez de enseñarla.
|
||||
|
||||
NOVEDADES 1.18.1
|
||||
- Al borrar algo grande se ve cuánto se ha liberado y el tiempo que lleva, y el
|
||||
espacio libre del explorador se actualiza mientras tanto.
|
||||
|
||||
NOVEDADES 1.18
|
||||
- Pestaña "Espacio": gráfico de quesitos y lista de todo lo que ocupa sitio en
|
||||
la consola, con el nombre de cada juego y su tamaño. Marca juegos repetidos
|
||||
|
||||
@@ -10,7 +10,8 @@ A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the
|
||||
- ✅ 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
|
||||
- ✅ 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
|
||||
|
||||
+42
-4
@@ -1,5 +1,5 @@
|
||||
PS5 DIRECTO 1.18 — USER GUIDE (English)
|
||||
=======================================
|
||||
PS5 DIRECTO 1.19.1 — USER GUIDE (English)
|
||||
=========================================
|
||||
|
||||
Paste links (or drag files from your PC) and the tool does the rest:
|
||||
|
||||
@@ -23,8 +23,15 @@ 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,
|
||||
@@ -220,6 +227,37 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo
|
||||
|
||||
WHAT'S NEW
|
||||
----------
|
||||
1.19.1 - Fixed: the queue stopped updating by itself (since 1.18.3), so
|
||||
"Remove" seemed to do nothing and then said "no existe". The file
|
||||
browser now relists the open folder when an upload to it finishes.
|
||||
Fixed "Download to PC" for games: ftpsrv decrypted eboot.bin/.prx/
|
||||
.sprx on the fly, so downloads came out incomplete. Files are now
|
||||
transferred exactly as stored (this also makes size comparisons
|
||||
when resuming folders correct). Thanks to RichyMX for finding it.
|
||||
1.19 - Renewed file browser: right-click menu, keyboard shortcuts,
|
||||
clickable path, drives and places on the left, "Move to…" / "Copy
|
||||
to…" with recent destinations, a choice when pasting over existing
|
||||
items (replace, merge, keep both or skip), "Extract…" for archives
|
||||
already on the console, and dragging files from the PC onto the
|
||||
open folder.
|
||||
- The file browser no longer needs BFpilot for most things: rename,
|
||||
move, delete, new folder, permissions, sizes and copy work over FTP.
|
||||
- Notification bell at the top: finished or failed transfers, file
|
||||
operations, console disconnections and new versions.
|
||||
1.18.4 - Fixed: leaving the window minimized or in the background for a while
|
||||
made the program close itself, showing "Failed to fetch" on return.
|
||||
Now it only closes when you actually close the window.
|
||||
1.18.3 - Fixed "Failed to fetch" and the queue appearing and disappearing: on
|
||||
Windows two PS5 Directo could end up listening on the same port (e.g.
|
||||
when opened twice in a row). Now only one can be open. Brief
|
||||
connection hiccups are retried automatically.
|
||||
1.18.2 - Creates a "PS5 Directo" shortcut on the desktop (and in "Send to")
|
||||
with the program's icon. If you delete it, it won't come back; if it
|
||||
pointed to an old copy, it's fixed. The taskbar shows the new icon.
|
||||
If an old version was still open (e.g. an old copy in another folder),
|
||||
it's closed automatically instead of being shown.
|
||||
1.18.1 - When deleting something big you see how much has been freed and how
|
||||
long it has taken, and the browser's free space updates meanwhile.
|
||||
1.18 - "Space" tab: a pie chart and list of everything that takes up space
|
||||
on the console, by game name and size. It flags repeated games (for
|
||||
example a folder and its converted version), half-sent transfers and
|
||||
|
||||
Binary file not shown.
@@ -22,7 +22,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import engine as E # noqa: E402
|
||||
|
||||
VERSION = "1.18.0"
|
||||
VERSION = "1.19.1"
|
||||
# Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas,
|
||||
# todas las copias buscan actualizaciones ahi sin tener que configurar nada.
|
||||
DEFAULT_REPO = "jstrdev/ps5directo"
|
||||
@@ -55,6 +55,8 @@ DEFAULTS = {
|
||||
"delete_archives": True,
|
||||
"tour_done": False, # la guia de bienvenida ya se vio
|
||||
"seen_version": "", # ultima version cuyas novedades ya se ensenaron
|
||||
"shortcut_done": False, # ya se creo el acceso directo del escritorio una vez
|
||||
"icon_ver": "", # version cuyo icono ya se dejo en la cache de la ventana
|
||||
}
|
||||
|
||||
LOCK = threading.RLock()
|
||||
@@ -79,6 +81,7 @@ def _save_json(path, obj):
|
||||
|
||||
def load_state():
|
||||
global JOBS
|
||||
load_notifs()
|
||||
try:
|
||||
with open(SETTINGS_FILE, encoding="utf-8") as f:
|
||||
SETTINGS.update(json.load(f))
|
||||
@@ -573,10 +576,49 @@ def record_history(job):
|
||||
"dest": job.get("dest") or "",
|
||||
})
|
||||
_save_json(HISTORY_FILE, hist[-500:])
|
||||
title = job.get("title") or "Transferencia"
|
||||
link = {"tab": "Transfers", "job": job["id"]}
|
||||
if job.get("status") == "hecho":
|
||||
notify("ok", "Terminado: %s" % title, ", ".join(job.get("result") or [])[:300], link)
|
||||
elif job.get("status") == "error":
|
||||
notify("err", "Error: %s" % title, job.get("error") or "", link)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
NOTIF_FILE = os.path.join(DATA_DIR, "notifications.json")
|
||||
NOTIF_LOCK = threading.Lock()
|
||||
NOTIFS = [] # lo mas nuevo primero
|
||||
|
||||
|
||||
def load_notifs():
|
||||
try:
|
||||
with open(NOTIF_FILE, encoding="utf-8") as f:
|
||||
NOTIFS[:] = json.load(f)[:100]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def save_notifs():
|
||||
with NOTIF_LOCK:
|
||||
data = list(NOTIFS)
|
||||
try:
|
||||
_save_json(NOTIF_FILE, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def notify(level, title, body="", link=None, key=None):
|
||||
"""Aviso para la campana. level: ok, info, warn, err. key evita repetir el mismo aviso."""
|
||||
with NOTIF_LOCK:
|
||||
if key and any(n.get("key") == key for n in NOTIFS):
|
||||
return
|
||||
NOTIFS.insert(0, {"id": secrets.token_hex(4), "t": time.time(), "level": level, "title": title,
|
||||
"body": (body or "")[:600], "link": link, "key": key, "read": False})
|
||||
del NOTIFS[100:]
|
||||
save_notifs()
|
||||
|
||||
|
||||
def read_history():
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as f:
|
||||
@@ -648,13 +690,177 @@ def fm_bf():
|
||||
return bf
|
||||
|
||||
|
||||
def _fm_start(op, items_total, fn):
|
||||
def fm_bf_opt():
|
||||
"""BFpilot si esta cargado, o None (entonces se hace por FTP)."""
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
return bf if bf.alive() else None
|
||||
|
||||
|
||||
# ---- lo mismo por FTP, para cuando BFpilot no esta cargado -------------------
|
||||
|
||||
def _ftp_entries(f, path):
|
||||
"""[(nombre, es_carpeta, tamano)] de una carpeta remota."""
|
||||
out = []
|
||||
try:
|
||||
for name, facts in f.mlsd(path, facts=["type", "size"]):
|
||||
t = facts.get("type")
|
||||
if t in ("file", "dir"):
|
||||
out.append((name, t == "dir", int(facts.get("size") or 0)))
|
||||
finally:
|
||||
try:
|
||||
f.voidcmd("TYPE I")
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _ftp_kind(f, path):
|
||||
"""'dir', 'file' o None."""
|
||||
parent, name = posixpath.dirname(path) or "/", posixpath.basename(path)
|
||||
try:
|
||||
for n, d, _ in _ftp_entries(f, parent):
|
||||
if n == name:
|
||||
return "dir" if d else "file"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _ftp_walk(f, root, cancel=None):
|
||||
"""(archivos [(ruta, rel, tamano)], carpetas [rel]) de una carpeta, de arriba abajo."""
|
||||
files, dirs = [], []
|
||||
stack = [""]
|
||||
while stack:
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
rel = stack.pop()
|
||||
cur = root + ("/" + rel if rel else "")
|
||||
for n, d, sz in _ftp_entries(f, cur):
|
||||
r = (rel + "/" + n).lstrip("/")
|
||||
if d:
|
||||
dirs.append(r)
|
||||
stack.append(r)
|
||||
else:
|
||||
files.append((cur + "/" + n, r, sz))
|
||||
return files, dirs
|
||||
|
||||
|
||||
def ftp_du(cfg, path):
|
||||
def go(f):
|
||||
k = _ftp_kind(f, path)
|
||||
if k == "file":
|
||||
return int(f.size(path) or 0)
|
||||
files, _ = _ftp_walk(f, path)
|
||||
return sum(sz for _, _, sz in files)
|
||||
return E.Ftp(cfg).call(go)
|
||||
|
||||
|
||||
def ftp_rmtree(cfg, path, cancel, on_item=None):
|
||||
def go(f):
|
||||
k = _ftp_kind(f, path)
|
||||
if k is None:
|
||||
return
|
||||
if k == "file":
|
||||
f.delete(path)
|
||||
return
|
||||
files, dirs = _ftp_walk(f, path, cancel)
|
||||
for i, (rp, _, _) in enumerate(files):
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
if on_item:
|
||||
on_item(rp, i, len(files))
|
||||
f.delete(rp)
|
||||
for d in sorted(dirs, key=lambda x: -x.count("/")):
|
||||
f.rmd(path + "/" + d)
|
||||
f.rmd(path)
|
||||
E.Ftp(cfg).call(go)
|
||||
|
||||
|
||||
def ftp_chmod_tree(cfg, path, cancel):
|
||||
def go(f):
|
||||
n = 1
|
||||
f.sendcmd("SITE CHMOD 777 " + path)
|
||||
if _ftp_kind(f, path) == "dir":
|
||||
files, dirs = _ftp_walk(f, path, cancel)
|
||||
for d in dirs:
|
||||
f.sendcmd("SITE CHMOD 777 %s/%s" % (path, d))
|
||||
for rp, _, _ in files:
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
f.sendcmd("SITE CHMOD 777 " + rp)
|
||||
n += len(files) + len(dirs)
|
||||
return n
|
||||
return E.Ftp(cfg).call(go)
|
||||
|
||||
|
||||
def ftp_relay_copy(cfg, src, target, cancel, progress):
|
||||
"""Copia dentro de la consola pasando por el PC (sin BFpilot): lee por una conexion FTP
|
||||
y escribe por otra. progress(bytes_hechos, bytes_totales, ruta_actual)."""
|
||||
a = E.Ftp(cfg).connect()
|
||||
b = E.Ftp(cfg).connect()
|
||||
try:
|
||||
k = _ftp_kind(a, src)
|
||||
if k is None:
|
||||
raise E.PipeError("No existe %s" % src)
|
||||
if k == "file":
|
||||
files, dirs = [(src, "", int(a.size(src) or 0))], []
|
||||
else:
|
||||
files, dirs = _ftp_walk(a, src, cancel)
|
||||
E.Ftp.mkdirs(b, target)
|
||||
for d in dirs:
|
||||
E.Ftp.mkdirs(b, target + "/" + d)
|
||||
total = sum(sz for _, _, sz in files)
|
||||
done = 0
|
||||
for rp, rel, sz in files:
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
dst = target + ("/" + rel if rel else "")
|
||||
E.Ftp.mkdirs(b, posixpath.dirname(dst))
|
||||
cr = a.transfercmd("RETR " + rp)
|
||||
cw = b.transfercmd("STOR " + dst)
|
||||
try:
|
||||
while True:
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
chunk = cr.recv(E.MiB)
|
||||
if not chunk:
|
||||
break
|
||||
cw.sendall(chunk)
|
||||
done += len(chunk)
|
||||
progress(done, total, rp)
|
||||
finally:
|
||||
cr.close()
|
||||
cw.close()
|
||||
a.voidresp()
|
||||
b.voidresp()
|
||||
got = E.Ftp.size(b, dst)
|
||||
if got is not None and got != sz:
|
||||
raise E.PipeError("La copia de %s salio incompleta (%s de %s)" % (rp, got, sz))
|
||||
finally:
|
||||
for f in (a, b):
|
||||
try:
|
||||
f.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- operaciones largas con progreso -----------------------------------------
|
||||
|
||||
OP_DONE = {"copy": "Copiado", "move": "Movido", "delete": "Borrado", "download": "Descargado al PC",
|
||||
"chmod": "Permisos puestos", "extract": "Extraído"}
|
||||
OP_FAIL = {"copy": "Error al copiar", "move": "Error al mover", "delete": "Error al borrar",
|
||||
"download": "Error al descargar", "chmod": "Error al poner permisos", "extract": "Error al extraer"}
|
||||
|
||||
|
||||
def _fm_start(op, items_total, fn, where=None, names=None):
|
||||
with LOCK:
|
||||
if FMOP["busy"]:
|
||||
raise E.PipeError("Ya hay una operacion en marcha en el explorador")
|
||||
FMOP.update(busy=True, op=op, current="", items_done=0, items_total=items_total, bytes_done=0,
|
||||
bytes_total=0, error="", message="", t0=time.time(), local="")
|
||||
FM_CANCEL.clear()
|
||||
what = ", ".join((names or [])[:3]) + ("…" if names and len(names) > 3 else "")
|
||||
link = {"tab": "Files", "path": where} if where else None
|
||||
|
||||
def run():
|
||||
try:
|
||||
@@ -662,6 +868,10 @@ def _fm_start(op, items_total, fn):
|
||||
with LOCK:
|
||||
if not FMOP["message"]:
|
||||
FMOP["message"] = "Hecho"
|
||||
msg = FMOP["message"]
|
||||
if time.time() - FMOP["t0"] > 8 or op in ("extract",):
|
||||
notify("ok", "%s: %s" % (OP_DONE.get(op, op), what) if what else OP_DONE.get(op, op),
|
||||
msg if msg != "Hecho" else "", link)
|
||||
except E.Cancelled:
|
||||
with LOCK:
|
||||
FMOP["error"] = "Cancelado"
|
||||
@@ -669,68 +879,232 @@ def _fm_start(op, items_total, fn):
|
||||
traceback.print_exc()
|
||||
with LOCK:
|
||||
FMOP["error"] = str(e)
|
||||
notify("err", "%s: %s" % (OP_FAIL.get(op, op), what) if what else OP_FAIL.get(op, op),
|
||||
str(e), link)
|
||||
finally:
|
||||
with LOCK:
|
||||
FMOP["busy"] = False
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def fm_paste(op, srcs, dst, overwrite=False):
|
||||
bf = fm_bf()
|
||||
def _names_in(path):
|
||||
try:
|
||||
return {e["name"]: e["dir"] for e in fm_list(path)["entries"]}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _free_name(name, taken):
|
||||
"""'juego' -> 'juego (2)'; 'a.zip' -> 'a (2).zip'."""
|
||||
base, ext = name, ""
|
||||
m = re.match(r"^(.*?)(\.[A-Za-z0-9]{1,5})$", name)
|
||||
if m and m.group(1):
|
||||
base, ext = m.group(1), m.group(2)
|
||||
k = 2
|
||||
while "%s (%d)%s" % (base, k, ext) in taken:
|
||||
k += 1
|
||||
return "%s (%d)%s" % (base, k, ext)
|
||||
|
||||
|
||||
def fm_conflicts(srcs, dst):
|
||||
dst = fm_clean(dst)
|
||||
have = _names_in(dst)
|
||||
out = []
|
||||
for s in srcs:
|
||||
s = fm_clean(s)
|
||||
n = posixpath.basename(s)
|
||||
if n in have:
|
||||
out.append({"name": n, "dir": have[n], "same": posixpath.dirname(s) == dst})
|
||||
return out
|
||||
|
||||
|
||||
def fm_paste(op, srcs, dst, mode="replace"):
|
||||
"""mode: replace (reemplaza archivos / combina carpetas), skip, keepboth."""
|
||||
dst = fm_clean(dst)
|
||||
srcs = [fm_clean(x) for x in srcs]
|
||||
if mode not in ("replace", "skip", "keepboth"):
|
||||
mode = "replace"
|
||||
for s in srcs:
|
||||
if dst == s or dst.startswith(s + "/"):
|
||||
raise E.PipeError("No se puede %s una carpeta dentro de si misma" % ("mover" if op == "move" else "copiar"))
|
||||
cfg = cfg_snapshot()
|
||||
bf = fm_bf_opt()
|
||||
|
||||
def bf_job(kind, s, d, overwrite):
|
||||
bf._req("POST", "/api/fs/" + kind, form={"src": s, "dst": d, "overwrite": "1" if overwrite else "0"})
|
||||
while True:
|
||||
time.sleep(0.7)
|
||||
if FM_CANCEL.is_set():
|
||||
try:
|
||||
bf._req("GET", "/api/fs/job/cancel")
|
||||
except Exception:
|
||||
pass
|
||||
j = bf._req("GET", "/api/fs/job/status")
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
||||
current=j.get("current") or s)
|
||||
if not j.get("busy"):
|
||||
if j.get("error"):
|
||||
raise E.PipeError(j["error"])
|
||||
break
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
|
||||
def relay(s, target):
|
||||
def prog(done, total, cur):
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=done, bytes_total=total, current=cur)
|
||||
ftp_relay_copy(cfg, s, target, FM_CANCEL, prog)
|
||||
|
||||
def work():
|
||||
taken = set(_names_in(dst))
|
||||
for i, s in enumerate(srcs):
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
name = posixpath.basename(s)
|
||||
same_dir = posixpath.dirname(s) == dst
|
||||
with LOCK:
|
||||
FMOP.update(current=s, items_done=i, bytes_done=0, bytes_total=0)
|
||||
if op == "move" and posixpath.dirname(s) == dst:
|
||||
if op == "move" and same_dir:
|
||||
continue
|
||||
bf._req("POST", "/api/fs/" + op, form={"src": s, "dst": dst, "overwrite": "1" if overwrite else "0"})
|
||||
while True:
|
||||
time.sleep(0.7)
|
||||
if FM_CANCEL.is_set():
|
||||
new = name
|
||||
if name in taken:
|
||||
if mode == "skip":
|
||||
continue
|
||||
if mode == "keepboth" or same_dir:
|
||||
new = _free_name(name, taken)
|
||||
taken.add(new)
|
||||
target = dst.rstrip("/") + "/" + new
|
||||
if bf is not None:
|
||||
if new == name:
|
||||
bf_job(op, s, dst, mode == "replace")
|
||||
else:
|
||||
# BFpilot copia con el mismo nombre: a una carpeta temporal y luego se renombra
|
||||
tmp = "%s/.ps5d-%s" % (dst.rstrip("/"), secrets.token_hex(3))
|
||||
bf.mkdir(tmp)
|
||||
try:
|
||||
bf._req("GET", "/api/fs/job/cancel")
|
||||
bf_job(op, s, tmp, False)
|
||||
bf.rename(tmp + "/" + name, target)
|
||||
finally:
|
||||
try:
|
||||
bf.delete(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if op == "move":
|
||||
try:
|
||||
E.Ftp(cfg).call(lambda f: f.rename(s, target))
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
j = bf._req("GET", "/api/fs/job/status")
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
||||
current=j.get("current") or s)
|
||||
if not j.get("busy"):
|
||||
if j.get("error"):
|
||||
raise E.PipeError(j["error"])
|
||||
break
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
pass # otro disco o ya existe: copia y luego borra
|
||||
relay(s, target)
|
||||
if op == "move":
|
||||
ftp_rmtree(cfg, s, FM_CANCEL)
|
||||
with LOCK:
|
||||
FMOP["items_done"] = len(srcs)
|
||||
_fm_start(op, len(srcs), work)
|
||||
_fm_start(op, len(srcs), work, where=dst, names=[posixpath.basename(x) for x in srcs])
|
||||
|
||||
|
||||
def fm_delete(paths):
|
||||
bf = fm_bf()
|
||||
paths = [fm_clean(x) for x in paths]
|
||||
for p in paths:
|
||||
if p.count("/") < 2:
|
||||
raise E.PipeError("Por seguridad no borro carpetas de primer nivel (%s)" % p)
|
||||
bf = fm_bf_opt()
|
||||
cfg = cfg_snapshot()
|
||||
|
||||
def free_now():
|
||||
if bf is None:
|
||||
return None
|
||||
try:
|
||||
return int(bf._req("GET", "/api/fs/stat", {"path": paths[0].rsplit("/", 1)[0] or "/"},
|
||||
timeout=8).get("availableBytes") or 0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def work():
|
||||
start = free_now()
|
||||
last = {"t": 0.0}
|
||||
|
||||
def tick():
|
||||
# Borrar un juego grande tarda: ensenamos lo que se va liberando para que se vea que avanza
|
||||
if start is None or time.time() - last["t"] < 3:
|
||||
return
|
||||
last["t"] = time.time()
|
||||
now = free_now()
|
||||
if now is not None:
|
||||
with LOCK:
|
||||
FMOP["bytes_done"] = max(0, now - start)
|
||||
FMOP["message"] = ""
|
||||
for i, p in enumerate(paths):
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
with LOCK:
|
||||
FMOP.update(current=p, items_done=i)
|
||||
bf.delete(p, FM_CANCEL)
|
||||
if bf is not None:
|
||||
bf.delete(p, FM_CANCEL, on_tick=tick)
|
||||
else:
|
||||
ftp_rmtree(cfg, p, FM_CANCEL,
|
||||
lambda rp, k, n: FMOP.update(current=rp))
|
||||
tick()
|
||||
with LOCK:
|
||||
FMOP["items_done"] = len(paths)
|
||||
_fm_start("delete", len(paths), work)
|
||||
if FMOP["bytes_done"]:
|
||||
FMOP["message"] = "Hecho: liberados %s" % human(FMOP["bytes_done"])
|
||||
_fm_start("delete", len(paths), work, where=posixpath.dirname(paths[0]) if paths else None,
|
||||
names=[posixpath.basename(x) for x in paths])
|
||||
|
||||
|
||||
def fm_du(path):
|
||||
path = fm_clean(path)
|
||||
bf = fm_bf_opt()
|
||||
if bf is not None:
|
||||
return bf.du(path, timeout=300)
|
||||
return ftp_du(cfg_snapshot(), path)
|
||||
|
||||
|
||||
def fm_extract(src, folder, password="", exists="keepboth"):
|
||||
"""Descomprime un .rar/.7z/.zip que ya esta en la consola, en una carpeta a su lado."""
|
||||
src = fm_clean(src)
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
if not bf.alive():
|
||||
raise E.PipeError("Descomprimir en la consola necesita BFpilot cargado en la PS5")
|
||||
parent, name = posixpath.dirname(src) or "/", posixpath.basename(src)
|
||||
if not E.is_archive(name):
|
||||
raise E.PipeError("Eso no es un comprimido (.rar, .7z o .zip)")
|
||||
have = _names_in(parent)
|
||||
# de un .part3.rar o un .7z.002 se extrae desde el primero
|
||||
group = [n for n in have if not have[n] and E.set_key(n) == E.set_key(name) and E.is_archive(n)]
|
||||
first = E.first_volume(group or [name]) or name
|
||||
src = parent.rstrip("/") + "/" + first
|
||||
folder = (folder or "").strip().strip("/")
|
||||
if not folder or "/" in folder or folder in (".", ".."):
|
||||
raise E.PipeError("Pon un solo nombre de carpeta, sin /")
|
||||
if folder in have: # BFpilot no extrae sobre una carpeta que ya existe
|
||||
folder = _free_name(folder, set(have))
|
||||
dst = parent.rstrip("/") + "/" + folder
|
||||
|
||||
def work():
|
||||
with LOCK:
|
||||
FMOP.update(current=first)
|
||||
|
||||
def st(j):
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=int(j.get("bytesWritten") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
||||
current=j.get("current") or first)
|
||||
try:
|
||||
j = bf.extract(src, dst, password, FM_CANCEL, st)
|
||||
except E.PipeError as e:
|
||||
if E.is_password_error(str(e)):
|
||||
raise E.PipeError("Contraseña incorrecta o necesaria: vuelve a intentarlo con la contraseña")
|
||||
raise
|
||||
try:
|
||||
bf.fix_perms(dst, FM_CANCEL)
|
||||
except Exception:
|
||||
pass
|
||||
with LOCK:
|
||||
FMOP.update(items_done=1, message="Extraído en %s" % dst)
|
||||
_fm_start("extract", 1, work, where=parent, names=[first])
|
||||
|
||||
|
||||
def fm_download(paths):
|
||||
@@ -788,11 +1162,12 @@ def fm_download(paths):
|
||||
with LOCK:
|
||||
FMOP.update(items_done=len(files), bytes_done=done,
|
||||
message="Guardado en %s" % root_local)
|
||||
_fm_start("download", len(paths), work)
|
||||
_fm_start("download", len(paths), work, names=[posixpath.basename(x) for x in paths])
|
||||
|
||||
|
||||
def fm_chmod(paths):
|
||||
bf = fm_bf()
|
||||
bf = fm_bf_opt()
|
||||
cfg = cfg_snapshot()
|
||||
paths = [fm_clean(x) for x in paths]
|
||||
|
||||
def work():
|
||||
@@ -800,7 +1175,10 @@ def fm_chmod(paths):
|
||||
for i, p in enumerate(paths):
|
||||
with LOCK:
|
||||
FMOP.update(current=p, items_done=i)
|
||||
c, _, _ = bf.fix_perms(p, FM_CANCEL)
|
||||
if bf is not None:
|
||||
c, _, _ = bf.fix_perms(p, FM_CANCEL)
|
||||
else:
|
||||
c = ftp_chmod_tree(cfg, p, FM_CANCEL)
|
||||
changed += c
|
||||
with LOCK:
|
||||
FMOP.update(items_done=len(paths), message="Permisos 777 puestos en %d elementos" % changed)
|
||||
@@ -975,10 +1353,38 @@ def fm_api(p, b):
|
||||
if p == "/api/space/cancel":
|
||||
SPACE_CANCEL.set()
|
||||
return {}
|
||||
if p == "/api/fm/stat":
|
||||
st = E.BFpilot(cfg_snapshot())._req("GET", "/api/fs/stat", {"path": fm_clean(b.get("path") or "/data")}, timeout=8)
|
||||
return {"free": st.get("availableBytes"), "total": st.get("usableTotalBytes") or st.get("totalBytes")}
|
||||
if p == "/api/fm/list":
|
||||
return fm_list(b.get("path") or "/data")
|
||||
if p == "/api/fm/du":
|
||||
return {"size": fm_bf().du(fm_clean(b.get("path")))}
|
||||
return {"size": fm_du(b.get("path"))}
|
||||
if p == "/api/fm/dirs":
|
||||
path = fm_clean(b.get("path") or "/data")
|
||||
return {"path": path, "dirs": sorted([e["name"] for e in fm_list(path)["entries"] if e["dir"]],
|
||||
key=str.lower)}
|
||||
if p == "/api/fm/conflicts":
|
||||
return {"conflicts": fm_conflicts(b.get("srcs") or [], b.get("dst") or "/")}
|
||||
if p == "/api/fm/extract":
|
||||
fm_extract(b.get("src"), b.get("folder"), str(b.get("password") or ""), b.get("exists") or "keepboth")
|
||||
return {}
|
||||
if p == "/api/fm/drives":
|
||||
bf = fm_bf_opt()
|
||||
out = [{"path": "/data", "name": "Almacenamiento interno"}]
|
||||
if bf is not None:
|
||||
for m in (bf.places() or {}).get("mounts") or []:
|
||||
mp = (m.get("path") or "").rstrip("/")
|
||||
if re.match(r"^/mnt/(usb|ext)\d+$", mp):
|
||||
out.append({"path": mp, "name": ("USB " if "/usb" in mp else "Disco externo ") + mp[-1]})
|
||||
for d in out:
|
||||
try:
|
||||
st = bf._req("GET", "/api/fs/stat", {"path": d["path"]}, timeout=6)
|
||||
d["free"] = st.get("availableBytes")
|
||||
d["total"] = st.get("usableTotalBytes") or st.get("totalBytes")
|
||||
except Exception:
|
||||
pass
|
||||
return {"drives": out, "bfpilot": bf is not None}
|
||||
if p == "/api/fm/mkdir":
|
||||
path = fm_clean(b.get("path"))
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
@@ -989,10 +1395,10 @@ def fm_api(p, b):
|
||||
return {}
|
||||
if p == "/api/fm/rename":
|
||||
src, dst = fm_clean(b.get("src")), fm_clean(b.get("dst"))
|
||||
if posixpath.basename(dst) in _names_in(posixpath.dirname(dst) or "/"):
|
||||
raise E.PipeError("Ya existe algo llamado %s en esa carpeta" % posixpath.basename(dst))
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
if bf.alive():
|
||||
if bf.exists(dst):
|
||||
raise E.PipeError("Ya existe %s" % dst)
|
||||
bf.rename(src, dst)
|
||||
else:
|
||||
E.Ftp(cfg_snapshot()).call(lambda f: f.rename(src, dst))
|
||||
@@ -1001,7 +1407,7 @@ def fm_api(p, b):
|
||||
op = b.get("op")
|
||||
if op not in ("copy", "move"):
|
||||
raise E.PipeError("operacion no valida")
|
||||
fm_paste(op, b.get("srcs") or [], b.get("dst"), bool(b.get("overwrite")))
|
||||
fm_paste(op, b.get("srcs") or [], b.get("dst"), b.get("mode") or ("replace" if b.get("overwrite") else "replace"))
|
||||
return {}
|
||||
if p == "/api/fm/delete":
|
||||
fm_delete(b.get("paths") or [])
|
||||
@@ -1027,6 +1433,26 @@ def fm_api(p, b):
|
||||
return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")}
|
||||
if p == "/api/history":
|
||||
return {"items": read_history()}
|
||||
if p == "/api/notifs":
|
||||
with NOTIF_LOCK:
|
||||
return {"items": list(NOTIFS), "unread": sum(1 for n in NOTIFS if not n.get("read"))}
|
||||
if p == "/api/notifs/read":
|
||||
with NOTIF_LOCK:
|
||||
for n in NOTIFS:
|
||||
if b.get("all") or n["id"] == b.get("id"):
|
||||
n["read"] = not b.get("unread")
|
||||
save_notifs()
|
||||
return {}
|
||||
if p == "/api/notifs/remove":
|
||||
with NOTIF_LOCK:
|
||||
NOTIFS[:] = [n for n in NOTIFS if n["id"] != b.get("id")]
|
||||
save_notifs()
|
||||
return {}
|
||||
if p == "/api/notifs/clear":
|
||||
with NOTIF_LOCK:
|
||||
NOTIFS[:] = []
|
||||
save_notifs()
|
||||
return {}
|
||||
if p == "/api/history/clear":
|
||||
_save_json(HISTORY_FILE, [])
|
||||
return {}
|
||||
@@ -2031,7 +2457,28 @@ def extract_worker():
|
||||
TOKEN = secrets.token_urlsafe(18)
|
||||
|
||||
|
||||
CONN_STATE = {"ftp": None, "bf": None}
|
||||
|
||||
|
||||
def _conn_change(kind, ok, detail):
|
||||
"""Avisa solo cuando algo que funcionaba deja de funcionar (y cuando vuelve)."""
|
||||
name = "el FTP" if kind == "ftp" else "BFpilot"
|
||||
prev = CONN_STATE[kind]
|
||||
CONN_STATE[kind] = ok
|
||||
if prev is True and not ok:
|
||||
notify("warn", "Se perdió la conexión con %s de la PS5" % name, detail)
|
||||
elif prev is False and ok:
|
||||
notify("info", "Otra vez conectado con %s de la PS5" % name)
|
||||
|
||||
|
||||
def test_connection(cfg):
|
||||
out = _test_connection(cfg)
|
||||
_conn_change("ftp", out["ftp"] == "ok", out["ftp"])
|
||||
_conn_change("bf", str(out["bf"]).startswith("ok"), out["bf"])
|
||||
return out
|
||||
|
||||
|
||||
def _test_connection(cfg):
|
||||
out = {"ftp": None, "bf": None, "free": None}
|
||||
try:
|
||||
f = E.Ftp(cfg).connect()
|
||||
@@ -2535,7 +2982,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path == "/logo.svg":
|
||||
with open(os.path.join(HERE, "web", "logo.svg"), "rb") as f:
|
||||
return self._send(200, f.read(), "image/svg+xml")
|
||||
if self.path == "/favicon.ico":
|
||||
if self.path.startswith("/icon-") and self.path.split("?")[0].endswith(".png"):
|
||||
n = re.sub(r"\D", "", self.path.split("?")[0])
|
||||
fp = os.path.join(HERE, "web", "icon-%s.png" % n)
|
||||
if n and os.path.isfile(fp):
|
||||
with open(fp, "rb") as f:
|
||||
return self._send(200, f.read(), "image/png")
|
||||
if self.path.split("?")[0] == "/manifest.webmanifest":
|
||||
man = {"name": "PS5 Directo", "short_name": "PS5 Directo", "display": "standalone",
|
||||
"background_color": "#0f1115", "theme_color": "#0f1115", "start_url": "/",
|
||||
"icons": [{"src": "/icon-%d.png?v=%s" % (n, VERSION), "sizes": "%dx%d" % (n, n),
|
||||
"type": "image/png"} for n in (192, 256, 512)]}
|
||||
return self._send(200, json.dumps(man).encode(), "application/manifest+json")
|
||||
if self.path.split("?")[0] == "/favicon.ico":
|
||||
with open(os.path.join(HERE, "web", "ps5directo.ico"), "rb") as f:
|
||||
return self._send(200, f.read(), "image/x-icon")
|
||||
if self.path in ("/", "/index.html") or self.path.startswith("/?"):
|
||||
@@ -2548,6 +3007,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if self.headers.get("X-UI"):
|
||||
UI_SEEN["t"] = time.time()
|
||||
UI_SEEN["bye"] = 0.0
|
||||
with LOCK:
|
||||
jobs = []
|
||||
for j in JOBS:
|
||||
@@ -2555,7 +3015,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
d["has_password"] = bool(j.get("password"))
|
||||
d["pcx"] = (j.get("info") or {}).get("mode") == "pcx"
|
||||
jobs.append(d)
|
||||
st = {"ok": True, "version": VERSION, "settings": dict(SETTINGS), "jobs": jobs,
|
||||
st = {"ok": True, "version": VERSION, "home": HERE,
|
||||
"unread": sum(1 for n in NOTIFS if not n.get("read")), "settings": dict(SETTINGS), "jobs": jobs,
|
||||
"inbox": len(INBOX), "repo": SETTINGS.get("update_repo") or DEFAULT_REPO}
|
||||
st["settings"].pop("ftp_pass", None)
|
||||
return self._send(200, st)
|
||||
@@ -2567,6 +3028,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
or o.startswith("extension://")
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.split("?")[0] == "/api/bye":
|
||||
# la ventana se esta cerrando (sendBeacon no deja poner cabeceras: token en la URL)
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
if (q.get("t") or [""])[0] == TOKEN:
|
||||
UI_SEEN["bye"] = time.time()
|
||||
return self._send(200, {"ok": True})
|
||||
if self.path == "/api/ext/capture":
|
||||
if not self._from_extension():
|
||||
return self._send(403, {"ok": False, "error": "solo desde la extension"})
|
||||
@@ -2617,6 +3084,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
ver, url, notes = github_latest(update_repo())
|
||||
except Exception as e:
|
||||
return self._send(200, {"ok": False, "error": str(e)})
|
||||
if _ver_tuple(ver) > _ver_tuple(VERSION):
|
||||
notify("info", "Hay una versión nueva: %s" % ver, "Pulsa Actualizar arriba para instalarla.",
|
||||
{"action": "update"}, key="update:" + ver)
|
||||
return self._send(200, {"ok": True, "current": VERSION, "latest": ver, "notes": notes,
|
||||
"newer": _ver_tuple(ver) > _ver_tuple(VERSION)})
|
||||
if p == "/api/update/github":
|
||||
@@ -2674,7 +3144,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
bases = ["/data/homebrew"]
|
||||
return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]})
|
||||
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p in ("/api/pick", "/api/history", "/api/history/clear"):
|
||||
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p.startswith("/api/notifs") or p in ("/api/pick", "/api/history", "/api/history/clear"):
|
||||
try:
|
||||
return self._send(200, dict(ok=True, **fm_api(p, b)))
|
||||
except E.PipeError as e:
|
||||
@@ -2853,10 +3323,48 @@ def locate_files(files, budget=12.0, max_depth=6):
|
||||
return found
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
"""En Windows, SO_REUSEADDR deja que DOS programas escuchen en el mismo puerto y las
|
||||
peticiones se reparten al azar entre los dos ("Failed to fetch", la cola que aparece y
|
||||
desaparece). Aqui el puerto es exclusivo."""
|
||||
allow_reuse_address = os.name != "nt"
|
||||
daemon_threads = True
|
||||
|
||||
def server_bind(self):
|
||||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
super().server_bind()
|
||||
|
||||
|
||||
LOCK_PORT = 8776
|
||||
_instance_lock = []
|
||||
|
||||
|
||||
def take_instance_lock(wait=0.0):
|
||||
"""Solo un PS5 Directo a la vez: se queda con un puerto fijo mientras funciona.
|
||||
Devuelve False si otro ya lo tiene (tras esperar hasta `wait` segundos)."""
|
||||
end = time.time() + wait
|
||||
while True:
|
||||
s = socket.socket()
|
||||
try:
|
||||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
s.bind(("127.0.0.1", LOCK_PORT))
|
||||
_instance_lock.append(s)
|
||||
return True
|
||||
except OSError:
|
||||
s.close()
|
||||
if time.time() >= end:
|
||||
return False
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def pick_port():
|
||||
for port in (8777, 8778, 8779, 0):
|
||||
s = socket.socket()
|
||||
try:
|
||||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
s.bind(("127.0.0.1", port))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
@@ -2881,20 +3389,45 @@ def dropped_paths():
|
||||
return out
|
||||
|
||||
|
||||
def running_session():
|
||||
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando."""
|
||||
def running_session(full=False):
|
||||
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando
|
||||
(con full=True, (url_base, token, estado))."""
|
||||
try:
|
||||
with open(SESSION_FILE, encoding="utf-8") as f:
|
||||
ses = json.load(f)
|
||||
base = "http://127.0.0.1:%d" % int(ses["port"])
|
||||
req = urllib.request.Request(base + "/api/state", headers={"X-Token": ses["token"]})
|
||||
with urllib.request.urlopen(req, timeout=3) as r:
|
||||
json.load(r)
|
||||
return base, ses["token"]
|
||||
st = json.load(r)
|
||||
return (base, ses["token"], st) if full else (base, ses["token"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def replace_other_version(ses):
|
||||
"""Si lo que ya esta abierto es OTRA version (o la de otra carpeta, por ejemplo una copia
|
||||
vieja del escritorio) y no tiene nada en marcha, la cierra para abrir esta.
|
||||
Devuelve True si se cerro."""
|
||||
base, token, st = ses
|
||||
if st.get("version") == VERSION and st.get("home", HERE) == HERE:
|
||||
return False
|
||||
for j in st.get("jobs") or []:
|
||||
if j.get("status") not in ("hecho", "error", "cancelado", "pausado"):
|
||||
return False # esta trabajando: no se le corta
|
||||
try:
|
||||
req = urllib.request.Request(base + "/api/quit", data=b"{}",
|
||||
headers={"X-Token": token, "Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=5).read()
|
||||
except Exception:
|
||||
pass
|
||||
for _ in range(40):
|
||||
time.sleep(0.25)
|
||||
if not running_session():
|
||||
print("Cerrada la version %s que seguia abierta" % st.get("version", "antigua"))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def forward_to_running(ses, paths):
|
||||
base, token = ses
|
||||
try:
|
||||
@@ -2939,21 +3472,93 @@ def open_window(url):
|
||||
return None
|
||||
|
||||
|
||||
def _powershell(script, timeout=30):
|
||||
import subprocess
|
||||
return subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
|
||||
|
||||
def ensure_shortcuts():
|
||||
"""Acceso directo "PS5 Directo" en el escritorio y en "Enviar a", con el icono del programa.
|
||||
Se crea la primera vez; despues solo se corrige si apunta a otra carpeta (una copia vieja)."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
try:
|
||||
vbs = os.path.join(HERE, "PS5 Directo.vbs")
|
||||
ico = os.path.join(HERE, "web", "ps5directo.ico")
|
||||
if not os.path.isfile(vbs):
|
||||
return
|
||||
with LOCK:
|
||||
first = not SETTINGS.get("shortcut_done")
|
||||
q = lambda t: "'" + t.replace("'", "''") + "'"
|
||||
script = (
|
||||
"$w=New-Object -ComObject WScript.Shell; $first=$%s;"
|
||||
"foreach($f in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('SendTo'))){"
|
||||
" $p=Join-Path $f 'PS5 Directo.lnk'; $ex=Test-Path -LiteralPath $p;"
|
||||
" $l=$w.CreateShortcut($p);"
|
||||
" $want='\"'+%s+'\"';"
|
||||
" if($ex -and $l.Arguments -eq $want -and $l.IconLocation -like ('*'+%s+'*')){continue}"
|
||||
" if(-not $ex -and -not $first){continue}"
|
||||
" if($ex -and $l.Arguments -notlike '*PS5 Directo.vbs*'){continue}"
|
||||
" $l.TargetPath=(Join-Path $env:WINDIR 'System32\\wscript.exe'); $l.Arguments=$want;"
|
||||
" $l.WorkingDirectory=%s; $l.IconLocation=%s+',0'; $l.Description='PS5 Directo'; $l.Save();"
|
||||
" 'ok '+$p }"
|
||||
) % ("true" if first else "false", q(vbs), q(ico), q(HERE), q(ico))
|
||||
r = _powershell(script)
|
||||
if r.stdout.strip():
|
||||
print("Acceso directo:", r.stdout.strip().replace("\n", " | "))
|
||||
if r.returncode == 0 and first:
|
||||
with LOCK:
|
||||
SETTINGS["shortcut_done"] = True
|
||||
save_settings()
|
||||
except Exception as e:
|
||||
print("No se pudo crear el acceso directo:", e)
|
||||
|
||||
|
||||
def refresh_window_icon():
|
||||
"""La ventana guarda el icono en su cache: tras actualizar se borra para que la barra
|
||||
de tareas enseñe el icono actual y no el de una version vieja."""
|
||||
with LOCK:
|
||||
if SETTINGS.get("icon_ver") == VERSION:
|
||||
return
|
||||
SETTINGS["icon_ver"] = VERSION
|
||||
save_settings()
|
||||
prof = os.path.join(DATA_DIR, "ventana")
|
||||
for sub in ("Default", ""):
|
||||
for name in ("Favicons", "Favicons-journal"):
|
||||
try:
|
||||
os.remove(os.path.join(prof, sub, name))
|
||||
except OSError:
|
||||
pass
|
||||
import shutil
|
||||
for d in ("Web Applications", os.path.join("Default", "Web Applications")):
|
||||
shutil.rmtree(os.path.join(prof, d), ignore_errors=True)
|
||||
|
||||
|
||||
def busy():
|
||||
with LOCK:
|
||||
return any(j["status"] not in ("hecho", "error", "cancelado", "pausado") for j in JOBS)
|
||||
|
||||
|
||||
UI_SEEN = {"t": 0.0}
|
||||
UI_SEEN = {"t": 0.0, "bye": 0.0}
|
||||
|
||||
|
||||
def watch_ui(grace=180):
|
||||
"""Si la ventana lleva un rato cerrada y no hay nada en marcha, se cierra el programa.
|
||||
Si hay trabajo, sigue en segundo plano; abre PS5 Directo otra vez para verlo."""
|
||||
def watch_ui(grace=30, silent=6 * 3600):
|
||||
"""Se cierra el programa cuando cierras la ventana (la ventana avisa al cerrarse) y no hay
|
||||
nada en marcha. Si la ventana solo esta minimizada, el navegador la "duerme" y deja de
|
||||
preguntar: eso NO cuenta como cerrada (antes el programa se apagaba y al volver salia
|
||||
"Failed to fetch"). Por si el navegador se cuelga sin avisar, tras `silent` segundos
|
||||
sin noticias tambien se cierra."""
|
||||
while True:
|
||||
time.sleep(10)
|
||||
seen = UI_SEEN["t"]
|
||||
if seen and time.time() - seen > grace and not busy():
|
||||
seen, bye = UI_SEEN["t"], UI_SEEN["bye"]
|
||||
if busy() or not seen:
|
||||
continue
|
||||
now = time.time()
|
||||
if bye and bye >= seen - 1 and now - bye > grace:
|
||||
os._exit(0)
|
||||
if now - seen > silent:
|
||||
os._exit(0)
|
||||
|
||||
|
||||
@@ -2971,8 +3576,21 @@ def main():
|
||||
if not running_session():
|
||||
break
|
||||
time.sleep(0.5)
|
||||
ses = running_session()
|
||||
ses = running_session(full=True)
|
||||
if ses and replace_other_version(ses):
|
||||
ses = None
|
||||
if not ses and not take_instance_lock(wait=30 if "--after-update" in sys.argv else 4):
|
||||
# otro PS5 Directo esta arrancando ahora mismo (doble clic, por ejemplo): uso ese
|
||||
for _ in range(40):
|
||||
ses = running_session(full=True)
|
||||
if ses:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not ses:
|
||||
print("Ya hay otro PS5 Directo abierto")
|
||||
sys.exit(3)
|
||||
if ses:
|
||||
ses = ses[:2]
|
||||
if paths:
|
||||
forward_to_running(ses, paths)
|
||||
print("Anadido a la cola de PS5 Directo (ya estaba abierto)")
|
||||
@@ -2988,13 +3606,15 @@ def main():
|
||||
for target in (transfer_worker, extract_worker):
|
||||
threading.Thread(target=target, daemon=True).start()
|
||||
port = pick_port()
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
srv = Server(("127.0.0.1", port), Handler)
|
||||
try:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(SESSION_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"port": srv.server_address[1], "token": TOKEN}, f)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=ensure_shortcuts, daemon=True).start()
|
||||
refresh_window_icon()
|
||||
url = "http://127.0.0.1:%d/#t=%s" % (srv.server_address[1], TOKEN)
|
||||
print("PS5 Directo %s" % VERSION)
|
||||
print("Abre en el navegador: %s" % url)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -967,6 +967,21 @@ def clean_host(text):
|
||||
return t.strip(".").strip("[]"), port
|
||||
|
||||
|
||||
def raw_mode(f):
|
||||
"""ftpsrv (ps5-payload-dev) descifra al vuelo los SELF (eboot.bin, .prx, .sprx): al bajarlos
|
||||
llega el ELF de dentro, con otro tamano, y los listados tambien dan ese tamano. Su comando
|
||||
SELF lo activa/desactiva en cada conexion: lo dejamos apagado para ver los archivos tal
|
||||
cual estan. Los servidores que no lo conocen responden con error y no pasa nada.
|
||||
(Gracias a RichyMX por encontrarlo.)"""
|
||||
try:
|
||||
r = f.sendcmd("SELF")
|
||||
if "enabled" in r.lower():
|
||||
r = f.sendcmd("SELF")
|
||||
return "disabled" in r.lower()
|
||||
except ftplib.all_errors:
|
||||
return False
|
||||
|
||||
|
||||
class Ftp:
|
||||
def __init__(self, cfg):
|
||||
self.host = clean_host(cfg["ps5_ip"])[0]
|
||||
@@ -987,6 +1002,7 @@ class Ftp:
|
||||
f.login(self.user, self.pw)
|
||||
f.set_pasv(True)
|
||||
f.voidcmd("TYPE I")
|
||||
raw_mode(f)
|
||||
try:
|
||||
f.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
except Exception:
|
||||
@@ -1257,11 +1273,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"):
|
||||
@@ -1270,7 +1291,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"})
|
||||
@@ -1282,7 +1303,7 @@ 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, timeout=20):
|
||||
"""Tamano total de una carpeta (o archivo), o None."""
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
+723
-101
File diff suppressed because it is too large.
Load diff
+29
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -404,7 +404,7 @@ tr:hover .rowact{opacity:1}
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
const I18N_EXACT={"Novedades": "What's new", "Entendido": "Got it", "Se ha actualizado. Esto es lo nuevo:": "PS5 Directo has been updated. Here is what's new:", "Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa más y qué puedes borrar. Actividad guarda el historial.": "Transfers is the queue. In Console files you browse the PS5: see sizes, copy, move, rename, delete, download to the PC or upload files and folders. Space shows in a chart what takes up the most room and what you can delete. Activity keeps the history.", "Espacio": "Space", "Almacenamiento interno": "Internal storage", "Analizar": "Analyze", "Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.": "See what takes up space on the console, with names and sizes, to decide what to delete. It flags repeated games (for example a folder and its converted version), half-sent transfers and leftovers. Needs BFpilot.", "Todo lo que ocupa sitio": "Everything that takes up space", "Borrar seleccionados": "Delete selected", "Disco externo": "External drive", "Libre": "Free", "Abrir": "Open", "Borrando…": "Deleting…", "¿Borrar de la consola? No se puede deshacer.": "Delete from the console? This cannot be undone.", "Juegos instalados desde la PS5": "Games installed from the PS5", "Sistema y otros datos de la consola": "System and other console data", "Otros": "Other", "Borrado. Vuelvo a medir el espacio…": "Deleted. Measuring the space again…", "Envía archivos a tu consola por la red local, sin pasar por el disco del PC": "Send files to your console over your local network, without going through your PC's disk", "Guía": "Guide", "Guía rápida: para qué es cada cosa": "Quick guide: what each part does", "Extensión": "Extension", "Capturar enlaces desde el navegador sin copiar y pegar": "Capture links from your browser without copy and paste", "Actualizar": "Update", "Buscar o instalar una versión nueva": "Check for or install a new version", "Ajustes": "Settings", "Salir": "Quit", "Cierra PS5 Directo del todo": "Closes PS5 Directo completely", "Nueva transferencia": "New transfer", "Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…": "Direct links (one per line). Paste all parts together: .part1.rar, .part2.rar… or .001, .002…", "https://servidor/archivo.part1.rar": "https://server/file.part1.rar", "https://servidor/archivo.part2.rar": "https://server/file.part2.rar", "También vale la ruta de un archivo de tu PC, por ejemplo: D:\\Descargas\\archivo.rar": "A file path on your PC also works, for example: D:\\Downloads\\file.rar", "Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el": "Files on your PC: drop them on this page and I'll find them on your PC, or drag them onto the", "icono de PS5 Directo": "PS5 Directo icon", ", o clic derecho →": ", or right-click →", "Enviar a → PS5 Directo": "Send to → PS5 Directo", "Contraseña del comprimido (si la tiene)": "Archive password (if any)", "Opcional. Si la dejas vacía prueba las de Ajustes y las del nombre": "Optional. If empty, it tries the ones in Settings and the one in the file name", "Destino en la PS5": "Destination on the PS5", "Dónde descomprimir": "Where to extract", "En la PS5 (BFpilot)": "On the PS5 (BFpilot)", "En el PC (7-Zip)": "On the PC (7-Zip)", "Añadir a la cola": "Add to queue", "Si pegas enlaces de varios archivos distintos, los separa solo. «En el PC» usa 7-Zip y manda el contenido ya descomprimido y comprobado (más datos por el Wi-Fi, pero sin BFpilot).": "If you paste links to several different files, it splits them automatically. \"On the PC\" uses 7-Zip and sends the content already extracted and verified (more data over Wi-Fi, but no BFpilot needed).", "Cola": "Queue", "Quitar terminados": "Clear finished", "No hay nada en la cola. Pega enlaces arriba.": "The queue is empty. Paste links above.", "Transferencia": "Transfer", "Pausar": "Pause", "Reanudar": "Resume", "Cancelar": "Cancel", "Reintentar": "Retry", "Quitar": "Remove", "Test de velocidad": "Speed test", "Mide descarga, envío y las dos cosas a la vez": "Measures download, upload and both at once", "Bajar al PC": "Download to PC", "Descomprimir y enviar": "Extract and send", "Colocar": "Place", "Listo": "Done", "Enviar": "Send", "Descomprimir": "Extract", "Enviado": "Sent", "de": "of", "Velocidad a la PS5": "Speed to PS5", "Quedan": "Remaining", "Descompresión en la PS5": "Extraction on the PS5", "Archivos y registro": "Files and log", "Copiar registro": "Copy log", "Registro copiado. Pégalo donde quieras (Ctrl+V).": "Log copied. Paste it anywhere (Ctrl+V).", "Registro copiado.": "Log copiLine truncated
|
||||
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
|
||||
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'})();
|
||||
@@ -1143,6 +1143,7 @@ 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');
|
||||
@@ -1152,7 +1153,7 @@ async function fmPollOp(){
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user