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 0000000..85c4fb8
Binary files /dev/null and b/icon128.png differ
diff --git a/icon16.png b/icon16.png
new file mode 100644
index 0000000..b6a8ca2
Binary files /dev/null and b/icon16.png differ
diff --git a/icon32.png b/icon32.png
new file mode 100644
index 0000000..ef53dac
Binary files /dev/null and b/icon32.png differ
diff --git a/icon48.png b/icon48.png
new file mode 100644
index 0000000..0a3ac25
Binary files /dev/null and b/icon48.png differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8aaf489
--- /dev/null
+++ b/index.html
@@ -0,0 +1,981 @@
+
+
+
+
+
+PS5 Directo
+
+
+
+
+
+
+
+
PS5 Directo
+
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).