From d9c33091cac067660fd34115fbdcad9da5998e89 Mon Sep 17 00:00:00 2001
From: JesterDev <105895386+jstrdev@users.noreply.github.com>
Date: Fri, 16 Oct 2026 16:38:42 +0200
Subject: [PATCH] Version 1.17.0
---
LEEME.txt | 14 +-
README.md | 5 +-
README_EN.txt | 8 +-
app.py | 74 +++-
bg.js | 71 ++++
download | 4 +
engine.py | 271 ++++++++++++--
icon128.png | Bin 0 -> 11923 bytes
icon16.png | Bin 0 -> 778 bytes
icon32.png | Bin 0 -> 1816 bytes
icon48.png | Bin 0 -> 3067 bytes
index.html | 981 +++++++++++++++++++++++++++++++++++++++++++++++++
logo.svg | 30 ++
manifest.json | 11 +
popup.html | 20 +
popup.js | 42 +++
ps5directo.ico | Bin 0 -> 63686 bytes
17 files changed, 1484 insertions(+), 47 deletions(-)
create mode 100644 bg.js
create mode 100644 download
create mode 100644 icon128.png
create mode 100644 icon16.png
create mode 100644 icon32.png
create mode 100644 icon48.png
create mode 100644 index.html
create mode 100644 logo.svg
create mode 100644 manifest.json
create mode 100644 popup.html
create mode 100644 popup.js
create mode 100644 ps5directo.ico
diff --git a/LEEME.txt b/LEEME.txt
index 8dec95d..cf04d7f 100644
--- a/LEEME.txt
+++ b/LEEME.txt
@@ -1,4 +1,4 @@
-PS5 DIRECTO 1.16
+PS5 DIRECTO 1.17
================
(English: see README_EN.txt)
@@ -162,6 +162,15 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
Ajustes.
+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 +287,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.
diff --git a/README.md b/README.md
index e366007..8e26cc0 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,13 @@
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
+- ✅ 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
diff --git a/README_EN.txt b/README_EN.txt
index c2acda0..49c6f7d 100644
--- a/README_EN.txt
+++ b/README_EN.txt
@@ -1,4 +1,4 @@
-PS5 DIRECTO 1.16 — USER GUIDE (English)
+PS5 DIRECTO 1.17 — USER GUIDE (English)
=======================================
Paste links (or drag files from your PC) and the tool does the rest:
@@ -201,7 +201,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 +215,9 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo
WHAT'S NEW
----------
+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.
diff --git a/app.py b/app.py
index ce29f1f..ec1e790 100644
--- a/app.py
+++ b/app.py
@@ -21,7 +21,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import engine as E # noqa: E402
-VERSION = "1.16.0"
+VERSION = "1.17.0"
# Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas,
# todas las copias buscan actualizaciones ahi sin tener que configurar nada.
DEFAULT_REPO = "jstrdev/ps5directo"
@@ -397,7 +397,7 @@ def transfer_job(job, srcs, cancel):
return folder_job(job, srcs[0], cancel)
names = [s.name for s in srcs]
archives = [s for s in srcs if E.is_archive(s.name)]
- if archives and job_where(job) == "pc":
+ if archives and (job_where(job) == "pc" or any(E.is_tar(a.name) for a in archives)):
return pc_extract_job(job, srcs, cancel)
m = E.SPLIT_RAW_RE.match(srcs[0].name)
raw_split = (not archives and len(srcs) >= 1 and m
@@ -530,8 +530,10 @@ def raw_job(job, srcs, cancel):
target = dest + "/" + s.name
if s.kind == "folder":
setj(job, phase="Enviando carpeta: %s" % s.name)
- E.upload_tree(cfg, s, target, cancel, lambda m: jlog(job, m),
- lambda v, rel: prog(v))
+ sent, skipped, resumed = E.upload_tree(cfg, s, target, cancel, lambda m: jlog(job, m),
+ lambda v, rel: prog(v))
+ jlog(job, "Carpeta enviada: %d archivos nuevos, %d ya estaban iguales, %d continuados"
+ % (sent, skipped, resumed))
else:
setj(job, phase="Enviando %s" % s.name)
E.upload(cfg, target, s.size, lambda off, s=s: s.stream(off, cfg, cancel, meter),
@@ -1072,11 +1074,12 @@ def pc_extract_job(job, srcs, cancel):
cfg = cfg_snapshot()
cfg["_limit_bps"] = live_limit_bps
log = lambda m: jlog(job, m) # noqa: E731
- exe = E.find_7z(cfg.get("sevenzip_path"))
- if not exe:
+ names = [s.name for s in srcs]
+ tar = any(E.is_tar(n) for n in names)
+ exe = None if tar else E.find_7z(cfg.get("sevenzip_path"))
+ if not tar and not exe:
raise E.PipeError("Para descomprimir en el PC hace falta 7-Zip (gratis, 7-zip.org). Instalalo, "
"o pon la ruta de 7z.exe en Ajustes, o elige 'Descomprimir en la PS5'.")
- names = [s.name for s in srcs]
folder = os.path.join(pc_temp_root(cfg), E.safe_name(E.set_key(srcs[0].name)))
dl, up = Meter(), Meter()
phase = {"dl": True}
@@ -1098,6 +1101,9 @@ def pc_extract_job(job, srcs, cancel):
speed_label="Descarga al PC", sent_label="Bajado al PC", pc_step="bajar")
local, created = fetch_to_pc(job, srcs, cfg, cancel, dl, folder)
first = local[E.first_volume(names)]
+ if tar:
+ phase["dl"] = False
+ return pc_tar_job(job, srcs, cfg, cancel, local[names[0]], created, folder, up)
# 2) Leer el indice (con contrasena si hace falta)
setj(job, phase="Leyendo el contenido del comprimido", speed_label="", sent_label="",
@@ -1292,6 +1298,60 @@ def ftp_place(job, info, dest, cfg):
pass
+def pc_tar_job(job, srcs, cfg, cancel, path, created, folder, up):
+ """.tar / .tar.gz / .tar.bz2 / .tar.xz: se descomprime en el PC con Python (sin 7-Zip)."""
+ staging = "%s/extract/%s" % (WORK_ROOT, job["id"])
+ total = os.path.getsize(path)
+ sig = "tar|%s|%d" % (os.path.basename(path), total)
+ st = _pcx_state(job)
+ skip = int(st.get("done") or 0) if st.get("sig") == sig else 0
+ bf = E.BFpilot(cfg)
+ if skip:
+ try:
+ if not bf.alive() or not bf.exists(staging):
+ skip = 0
+ except Exception:
+ skip = 0
+ free = free_space(bf)
+ if free is not None:
+ jlog(job, "Espacio libre en la PS5: %s" % human(free))
+ if free < margin_bytes():
+ raise E.PipeError("No hay sitio en la PS5: quedan %s libres y el margen es %s. Libera espacio "
+ "y pulsa Reintentar." % (human(free), human(margin_bytes())))
+ jlog(job, "Descomprimiendo el .tar en el PC y enviando a la PS5")
+ setj(job, status="enviando", total=total, sent=0, pc_step="enviar", sent_label="Leído del comprimido",
+ speed_label="Velocidad (comprimido)", phase="Descomprimiendo en el PC y enviando a la PS5")
+ last = {"v": 0, "t": 0.0}
+
+ def progress(v, tot, name):
+ if v > last["v"]:
+ up(v - last["v"])
+ last["v"] = v
+ now = time.time()
+ if now - last["t"] > 0.5:
+ last["t"] = now
+ setj(job, sent=v, phase="Descomprimiendo en el PC y enviando: %s" % name.rsplit("/", 1)[-1][-60:])
+
+ def on_file(n):
+ _pcx_save(job, {"sig": sig, "done": n})
+ watch = start_space_watch(job, bf, cancel, "el envio")
+ try:
+ nfiles, nbytes, params = E.stream_extract_tar(path, staging, cfg, cancel, lambda m: jlog(job, m),
+ progress, on_file, skip)
+ finally:
+ watch.set()
+ setj(job, sent=total)
+ jlog(job, "Enviado ya descomprimido: %d archivos, %s" % (nfiles, human(nbytes)))
+ games = []
+ for pj, raw in params.items():
+ root = pj[:-len("sce_sys/param.json")].rstrip("/")
+ if any(root == g["root"] or root.startswith(g["root"] + "/") for g in games if g["root"]):
+ continue
+ games.append({"root": root, "tid": E.title_id(raw.decode("utf-8", "replace")) or E.title_id(root)})
+ return {"mode": "pcx", "staging": staging, "hint": E.display_name(srcs[0].name), "games": games,
+ "pc_files": created, "pc_folder": folder if created else ""}
+
+
def finish_pc_extract(job, info, bf, dest, cancel, cfg):
staging = info["staging"]
setj(job, status="colocando", phase="Colocando en su sitio")
diff --git a/bg.js b/bg.js
new file mode 100644
index 0000000..045c53b
--- /dev/null
+++ b/bg.js
@@ -0,0 +1,71 @@
+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;
+ }
+ try {
+ const r = await fetch(`http://127.0.0.1:${app.port}/api/ext/capture`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, original: item.url, name })
+ });
+ if (!r.ok) return;
+ } catch (e) { return; }
+ try { await chrome.downloads.cancel(item.id); } catch (e) {}
+ try { await chrome.downloads.erase({ id: item.id }); } catch (e) {}
+ const n = (cfg.captured || 0) + 1;
+ await chrome.storage.local.set({ captured: n, last: name });
+ chrome.action.setBadgeBackgroundColor({ color: "#2563eb" });
+ chrome.action.setBadgeText({ text: String(n % 1000) });
+ chrome.notifications.create({
+ type: "basic", iconUrl: "icon128.png", title: 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);
+});
diff --git a/download b/download
new file mode 100644
index 0000000..b3ddd3e
--- /dev/null
+++ b/download
@@ -0,0 +1,4 @@
+__pycache__/
+*.pyc
+.backup/
+*.nuevo
diff --git a/engine.py b/engine.py
index 538e763..afe6844 100644
--- a/engine.py
+++ b/engine.py
@@ -13,6 +13,7 @@ import ftplib
import json
import math
import os
+import posixpath
import re
import socket
import ssl
@@ -40,7 +41,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.+)\.(?P\d{3})$")
IMAGE_EXTS = (".exfat", ".ffpkg", ".ffpfs", ".ffpfsc", ".ufs")
@@ -48,6 +54,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 +70,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()
@@ -529,10 +537,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 +669,21 @@ 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):
"""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).
+ 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 +694,73 @@ 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
+ tasks.put((rdir + "/" + name, rel, size, offset))
finally:
w.close()
+ 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)
+ with lock:
+ st["done"] += offset # lo que ya habia en la consola cuenta como hecho
+ 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):
@@ -1402,3 +1532,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
diff --git a/icon128.png b/icon128.png
new file mode 100644
index 0000000000000000000000000000000000000000..85c4fb876599f7850c57e642ff8db4ef6f4d9047
GIT binary patch
literal 11923
zcmV;EE^N_>P)~68RlDl{|L=cQ?S0xfSsv4+jg#duT}}$XW4xRcfX8?_CIUc2**iXyznrxC
zO$9nFQ?%9m+niUHA7F7wjST_uHXp(omF{b8Uy=`VY_1Wj_$(^3#D@Fn>&%;{kKu
z0aLM`NbMOIZ2$dL|6}wk<~bTixhNc^a`)Xde)CWAf4$VfD_aWBF?8BoJT1@3{IdjD
zF8(g2=y>RhBa%G#Hx&GVBPL?zl$W);PPQ5Ub4~3zGp9U3I5Lx&N!qq4nMsz3R5G2P
zMNxm2>16wfo?m5tArr;l0|P)SQy6HOqxsiv*}l#Be$(cwW5d}O{_RV~zxu={su>)G
zvgQJC0`0Vhf1VKnejbT_oOSSHh8Vw}Wm3<73>ytG
zKAz!}jTzc)BM3v;$IjZaeEDl$vv~?@P>xstuKqF&O&ri~Q!RSEne67N8N#7iE7_St
zsx6d-u_v7e2bR<#z_6I%`O0whgsU~;FUvu@xRPXqC`HG+SejLsCmAYBHts0
zVuX|jjmQ_Uh<0YeA=TSt#K>n$&BnQi;*AhPrPO4Ro5b)669-k+{k*~7>Pu=CV*Ap{Q_a$bDp<(t2WRg_f;
zz@OZ>{GSJh)U}-s4Lvf6(yO;#Z4L6B%~$S8?ETtP=LkyVt3aes0>b%BIh!rL{nAU9
zOlF^fZF+RCK(@5)Y{M({sAv?;=N|cqP$a$p=^i$ph2Ak)`U3p7!;*_22xpwu#^4}~
zo2_2`!S`;y2`enw%5Eoh*(W<69vDV?BY(HxX
z+s;y?&;W*f?z(I5?kfmpHHvLt-tVM}h0nHk*Ij$!ak=}f(1rToP$uIC6I*)(TC?St
zgapAHnwIy&AHiTC2t>m@H&*kuztr3X6Cv9d^_>oI->*8DnW14LoS%OErIUY-6_yog
zfH&Qc|JU&mx_)|=FmYG~uii~wDe`p^`Qbh|v^(Z8@|E!ZnOG76k`~UED|zma@Bh}}
z{uIqY{lmxk68YBV+xEIH&L@`)A{hlZqhY5BD5=*FC{U963BL)Cdg-`GxLeh
zmGm9lpfbK+v?tfUxxdATM$jbQ>~KFq_`Js9@w%uaazGi`iYoOS&4ef=^bb^RcGESLX;mK;$!s`pQ5_XhR1AMjjJ~9Xfc_bF#)=FA;8$
z8eMnDmo~pN^E%cX>++|mos|KNJhwK=1b&wW5=)L_`wKt{dsfcG>N-zpDN;b
zSbF*XQ=4CoqJNw3ZitXCg`)t#0}plzle2vgxaFeVG>9IhrvR)Q(|=}Sm?u7R&`9z!
z1QV50mv#`P24FSK4pULbp&yv33aJCg4qjvq3Wv`8ow<&zg9jlr;XZIZw~e33LlHH0*g
z86McPT%4b`b9Vo`a3lh-`_r_o4f-!ra|-i|JP7TP#f{n9_Q-SIav)x?`5rkC(LfgJ
zy-g>b*nFyZ`(6`yrm4sQ<#BPI0b)FWhz5{oE?$*n=1Q9jw+10?zLHF^WYUg46e_%i
zQDQtqiDp25Hs4~*_$KePp_6@ZS`?=szv9y}~0w%{%!Ul4~i
zfTOJsvDn*}nuD3|>mzgEn3!dekhCJi%TQ0bkPv`Cet14A{Rj0;rAA=Q!jLZytPvzz
z%JU4dgO(!MX%u%~1|dZhl3)ShRpgVX=lcTk5!EXpJR}_3$;rG(KCPRbA9_1hBLHvz
zG@Wf|^Qy_i+6XTrUO5fmNrJG7g(yxAlXsyZYP@~K%(2JBrXzMBW9B0;6C&>*`lZBX
zhCBr$F~p3rJtMO=&X<}{FfT)3E+f*jJV~3+gB8g4YE~g2PN;Eq5My6}H3i%3-~f8$tC;!n
zx{?|Ji4gk!B@xL5D?IojD~VjqKvqES9iyY|}GbEria8&$aIw+3Ur0_Ns}I@XbG51UWeENUc7(sF#fq#chFfM9-i
zn@>$P9|7^P*$5h8c2*S08og%!@~`hypy5gb>Jq#Po!xKYTqR`ZGTu>Y0x7bH;}=$*+Y4
z{R-5SiU61aDb>QWW)>8JBZJ7Hi)=n(M=A3}5W+x+Ln#W5n^Np82jrJFJ>%=bK~(IW
zL)h$KW2J4CzQFUJHh@#NW>PycEo+ZYIJ<=l|K*S{Lx|#b5*i`+5`eVLA!>3UNPHj_
zF^eLb-!0Dfx2vSRE52UvXJ)3{sYpu22RKaX!j@^d*b!ba1QA4>Owv|+B4vrq7n1LezqU(Z>xO|+%}z`aetN$S
z>OE7w|FV4+p5t@=ta%{3a;3b@x5-%B?wm8)XtzCYpc;Y<*PaVK%JKANNJr6D8JGU=JpsU#Y8t`$cBHz`+
zCWeZBW*3&8Wlp~*`CZWf#`fK~YXSwT0oK{^R^+Y(%^gRFLcd55Ro*4kXXI@ojL$zGvF|Z7s3L@~
zj`L&now@ew)@4B~-1T+iN+7!q`Tf~^*K4@J)SG-jQMnzx
z1i%n=>v9giCoYow;s)NMC$?BeYD$wpfVfY`A&yQ=SeUGBm-|f`_Hy**j&kkEjVH00
z?43*9A!ye3d6CB;vDeU>4LcB?Sn0@Wq6GPIG(=)|vJ#*O!t3deBenS~B*l@_^988Q
zIJO}v6#-C%04xhf#sf@w^~J~!NJThGBJP63Ay^m2`pkWe`755Mrfqel7EPy#6`Hq!;-uMLW?;
z089dC$8s)^Up!nOO^Yqg5%MTOz6VH*3@x^6`B_rUHZ1oq?7A(EsKG)yxCT(e>
zBc@6wtDZvOGyqAB#sg)CGoD|{Jp1Mn;?+R;z_didBsD^ySB!kBMZUl88ZMhYL5|BZDzdi!fYo4oFs05-^s0f8>xS07t_*|S{LB2b8?$wrUk^4sAm7&e
z3}Jw_X9^KSNUOZ&Na^W|cZE5QM?|P?6)uOaxXF=SP|YQq#Z-ZOK1XRRJ|oK!!9Yrm@hR46vRgx!gCcGqJZ?+bY0{_eEvfR+v*)fa6N=HITFhS
zH6BQrYZMqLaf=Y;xuZ6TpB$Jc|&rG0E?RT;H
zS!DAGDtVqPRSh8HTklv&n~!MHUVTHw$s$2cX14s&27s7o(Z?GE@=3n(4Q~brtXYEQ
z@ah-g9Hdr;czGRRo)TeFh@-WDNZ4ZG$N(Qz#l8|ViEEe-XlB0W6L_GIHlI@D3%>Rg
zfEYQUQ3!uY5L@{;SNaUh97N1xBxy)l4EX_WZ(paaenO)ew)s(`>_KkqX8gjju5BE
z4x579%gEzi(NR~KulzCd#OCMwH3?>dPD1C(T8Kbo1^J=6Jobq5D>ff7@@q4X=#mHU
zS}kIlKr{lei3G9aWJzY|n3)1O&OwvNFTbuu_Jzu#Dv$a{gzWM@;Ukp&=UQkE84}_M
zgupSKq{eZte)Bu#(&oE*=|IQGSCZiVxF&HvRgo`OC3^~hWumZsU^Q(uf{*7*BDnjD
z>nh~zL+&J)kx(Ce1_*CD&io_;oc0YiE@%e35Mq%o5XZL~1+VD$;?nX1TL95
zKlISDAHc)r@5M!`*nCz+{l@uC$Zv`QDw{$>Zk@tTpccR9?vCD8Dd#irOBt3K`~oNY
z5Sd#&E9wix{_tnOP}`m_ENZtAlpr@+vTdt6n#ueA>~L!B+mopG!hnFZ$`SR~BZty!
zltM}~L>W`I58%8=*gTUvjUc6s)%|yGU&QdxVo9I*`4qv4&%pGNOt#@!SLPEX+xvP6
z0Hj1S@^$fg7hGqKsF_T31c&Y@Fq?1I+*-OW3lU)2WgkfrMK-uPaPalPYeqC
z3!=3F)sQCujz9w-nh%^0!|VTELkOXRV7@n>0(ohizqkN+&B8a(+S&ZzSZ1{vpk_Y+
zyyNDX0cAgSNP&qy58#pD)G-s!A&=js)9wS&By6>``F;kd2?)ZqY!-lW4sKWy
zQ9+Uw)KO{Nbl_E{zz&Wf4L)So{RQSiN+AmcSy^<5CmVQKV~-_gDQ}5KD=;{Kt4i<
z+K7CrMZV`5!TfT21tnR$chqQrgbHk4+ScfQ!Y(BVsy6!J+zGJfWB{HNx-;yU9CGz$m*Cyh4Jn{j6
zRUH+@&$Su}UI=(ojHllTX7eMVtI+_JL{J&R6z{qjB#Qk$PLW3A)-
zirGl&(^4M=2pgPh_66MaG|t!3HY$;U<9MEJq-`HEd>lH43?IJ-z-FQ7aePeSwZA{+
znV-(#L_B}ulZNot|GpXP*JVg;{!x$feSMA4sXlxF=fAiyl2^ypF|$qBhRe)g)0;ff
zMn-WyAz8hXNgnw6m%nfX8`fnJu^$b`?c3XU<;Clct<6u7PvU-sJ~cpsz+n3r`Rt(C
z{sNOJW^NR!u_i&3UM8w$2xU~UVSNivKG&*Xos9j_aQ^J)jFz9LJ0@|yM1G_ZgeIse
zfQBf5B$GJzpM}j1L}I5;bDU37BSg^wAVa6HjS7%UuV?Rc4jjOi&!4Bq6ZQH(J7-WJ
z|LDc}6`RlJ(REZxZ63gfP9>AA50EqRscC0U66dGtKIi;EyoZm4AWwXOB2Q3!GDRoq
zRVP$F(XWqFzJSR4R$fTCYd6~7W%C3-yJxv8vE`gj#OuXZIqvzVMXc$1J~f4mbvEDL
z!Lm@re?t_YU4~2U&o4W1uXP1q0P(I|9k1udn=-22zEy9T_cgp`Altt9(Sy5x(&;G#
zC+hKAx6T*8;?;#|YZ>Pw)&O!K@ZD8PO%z~{x%2Mh1sjpPp{6)L_z1-?q-{^VeS2Tx
zQ*gB4a_pb}%fpyB7{2)BiHwBkC)o93!{--1HyxO=W;UPtw)sIb*lx0Z@&LsxX(lep
z?Nj$SKYdM1QTI5XD9H;5O;8-?7Zk3!VG2{zx?cS%bXU*E;ri5}9M^qt5=)D;wl<$u
zZu4c9xlRStngDj}h)|p1W9CcEko)}rEF|e0J3@F6GJ%xrEXpa>3>p)YgsVO}joEpZ
zM{|=Rgvt5y<9vN)M&qjYPU676<=E!;jr=2v^OdVF$K@EBad$PPRs+B}DDsUR|B?gh
z<9ut&wSPD$t6#UTMLvKinFPr10u(~Ee}9e-Uq4muorh4nj;7K1@w#4ollMdKo5bEn
zmT`RJe6H{A02Zb0OQC*NN{G5gW<#8x=gy0lZ|o#XzyaBzeIEG)HS75lp7Y>CIj;WL
zRQXZ`iOG?e
z%MeK0K<05i7UAAUjLrY$JslkHI6t-d
z-I!mLnmmA%Ob8)hrZ`7YsOyqRdAbuvtQ{!U41uYBZGc?pn8!YaTuX>o4S{>^?ck$k
zxt0ACl`!R4o)^M>!!-xXct5iF=#Kn~kkr}yR1>7guZ#03vH7CDKLHTPj|UT;lG<#m
z2371ZLU{E#Z;=oAxDy_YFdn0faAx1Ys+VKl*V8H-38Bt79C8V=>H6nF!@a
zcP`>YiSr|20K6~Yx~Y@~4ItW(kWBG#R`*&ugwop=Z}OA~7tw+BDLC`TKe6t1*5oq3
zVDHxyq7U8!Sloa69gFzv=cl8&c_tl$^Uwdq6z=%W+zA)whwBJJ1N6HZDAh@9dxC_;
zwu+I=Ie*Jbp&|SRP3|HHQy@wvY`9GIgLq#mX7
zzy9nrZu`a@PP8~bVm_)u(o+EBy0t{U_X&h>Knz~KC|MMa7Seb7oPzK^gU=)6)}Z@U
zLq>YEnn<=E#nA+n!t;&)JCDEp@@!yM6BF0y`d4n5!L5Hk6Wjd0k&mMu=X1Rm0wwBu
z34oHbcfFDvq_~ssqe${Zy>t{2YXm~D?KbTU_j;H@_%47GCfOdYJ;lkX|9!Jz%0JBe
z@rKbS%sj&Dx7|92TfT4@Cu*D@F`uf!ke2GMU?J*O@KOoZ-m{3Jh18~*^JhL~02lqM
z5uD{VcHIA9j?aH-4&VJjr?dwu3mZm@JVWM~tlZaC3dt^n!|y82;1;IBc*^VU)$^N-
zS}uMd>KPY|c(u5e$J@U>kH7qjLou^8b!9&9imvS5MdAyX<5Qn8f|tB<45y!EcR}sB
ze;KzJALobPU3ARlet29e0Utzvj!l|S-4_r_eI}5aZ9atLM`l6Adh_YOK7e<=c^%Gu
zLc81+Rs1Xde)n6~WB92C!|I&*qzvbY|%YJ8_;6rnq
z?}b1i8J`fEq|^#PH=8e+&x4oL;<255W(!T_jMG|T&|)?uZJH#qeNr5KAR?w8x3U`J
z`i~sM&wjS#>sNcc_vcIa_|*rEU{#sc4f!jL^UpZD`JIPT&uI6I^CKbf^h2!v`_ljc
z*$_ypLq69-Je;o=KM&VbioJf6QX7f6sr1j!M@>B-qm`nkj#)3b_UcL8e}6~b9hlq4
z`2(iy$F7>NKe<-NyfuvThev9$SeCK!{enK@d`SuSTmhaX<%$3)hq+xcG8=DH6|7(oIm>YP?-^Hb!LG@m`_
zC7Fx|c)h
zj}%aHUcAzE?G*>g<=tB59gR4@C%wX-5m?Y<^C_vX@3vX*LeoE|UGBJVF
z=2Mf+2P%m2qh|z|4tI%sp($9g@#A9`U0Hb0_k!*goi4o31FysFKY$VY;}{)8ZwV9!lJ5_N?(
zKM*Ccf$n*8a~d~Xdk~L%+#t?AXAnEj94MbvID7Y*wfy_?{Ut>gR@@%pXa`j^iQHeaR4uP|Q_Pt4;N_bi$4
zZn5%yeq%h}rN007^J|_XHB3)}p$7T!IKmHDyYWQNsn-CF?*_&a*N@E)l16=L0ErM=
z({G#~YaB_G2IixiZB)^7j#iwXB0n8}Z+ZbMv-xR#eW_gwh>(xih6LpEPh}
zAu$_4eCfTSEAqv-BIZjbB1S;cULx4vw??`B(F+b@>^TR^^Z8p(!Q5>dDu|Eg+q;za
z>FRtk71Iy3%$;o^OskLcyW0HX@5q<1^+)^t{ILrTRpvW#!6D(D$Lsms
z(!fQTKBytV;We2bKPvpF3TJ)ab*q6hUL^^p*V=qgBpg!W4o!6zfL$iPy;gC4H=7?Z
z$<-YU?8Qsc@UNDTcjPrq1SRi}}BnY?lM=Q>s`}W4leCPggBO-^)wJY{G
zT=?6g=vMaJw;c1sYZ4^Lck==eYc_>etE@ke_FeS>2>aU^?TV(_VDn?i=N9(xgS(gT
z!sid8sq8vy055&f2yXuJ93HhcA5@`Faera!X{LqI3n$CF=WpGNh1)jz{-l}x&6kh$
z)Br!ZW1+_8^K|9-Q{;#Fw%f%g_0;~f@48+l{;@+z}z%p
zWi}rbpQU9$trhmB{nxplj}#gpYZW_ii^lbFeiQO#{Y`&0gQq=ZGrE=HJAiMwWFwya
z%td_1XoCm$ESoj_Wz5a`@6#sCmrO*g{uFSm;{5LYF+D<@U##h$W>U{PcD8Wd1;cpq
z1;gm4-13>}Xx@DzUmV-5tkeL=)&1xt0EX;$v`nLQqb5$Du2=g_$d7EcDqi7Q#_iu<
z!ujV7qF1@#X+!0|lO>e9zHI<;@3Iv4zHNSprWA(9tjy%dzn4CvVgk0cH{4=u)WjHX
z>8nA$uNOmWTF<}cV^f%&?H&@IEY&h=@+hCXrpPBG=A@!&8qAcdFY+Lx>xyqHW8(0R
z?O#rxQxSmOyVZhO2Kw6gSj#`W3Hkl3=N~wrarp
Envía archivos a tu consola por la red local, sin pasar por el disco del PC
+
+
+ FTP
+ BFpilot
+
+
+
+
+
+
+
+
+
+
+
+
+
Nueva transferencia
+
+
+
Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el icono de PS5 Directo, o clic derecho → Enviar a → PS5 Directo.
+
+
+
+ Una carpeta se manda tal cual (si tiene sce_sys, como aplicación) y salta lo que ya esté igual en la consola.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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).
+
+
+
+
+
+
Cola
+
+
+
+
+
+
+
+
+
+
+
Archivos de la consola
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nombre
+
Tamaño
+
Modificado
+
Permisos
+
+
+
+
+
+
+
+
+
+
+
Actividad
+
Historial de transferencias terminadas o con error (las últimas 500).