From baa4616faf15172bed57167257ddebf3a0afe1e2 Mon Sep 17 00:00:00 2001 From: JesterDev <105895386+jstrdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:47:56 +0200 Subject: [PATCH] Version 1.18.0 --- LEEME.txt | 27 +++- README.md | 4 +- README_EN.txt | 24 +++- app.py | 275 +++++++++++++++++++++++++++++++++++++++- engine.py | 192 +++++++++++++++++++++++++--- extension/bg.js | 15 ++- extension/manifest.json | 37 ++++-- web/index.html | 230 +++++++++++++++++++++++++++++++-- 8 files changed, 759 insertions(+), 45 deletions(-) diff --git a/LEEME.txt b/LEEME.txt index cf04d7f..56bc770 100644 --- a/LEEME.txt +++ b/LEEME.txt @@ -1,4 +1,4 @@ -PS5 DIRECTO 1.17 +PS5 DIRECTO 1.18 ================ (English: see README_EN.txt) @@ -26,6 +26,12 @@ PESTAÑAS carpetas tal cual a la carpeta en la que estás. Copiar, mover, borrar y calcular tamaños necesitan BFpilot; sin él, el explorador funciona en solo lectura por FTP. + - Espacio: gráfico de quesitos con lo que ocupa sitio en la consola, con + el nombre de cada juego y su tamaño. Avisa de juegos repetidos (por + ejemplo la carpeta y su versión convertida a imagen), envíos a medias y + restos, y permite borrarlos desde ahí (pide confirmación). Necesita + BFpilot. Los juegos instalados desde la propia PS5 se ven pero se borran + desde la consola. - Actividad: historial de lo que se ha terminado o ha fallado. CARPETAS DEL PC @@ -162,6 +168,25 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en Ajustes. +NOVEDADES 1.18 +- Pestaña "Espacio": gráfico de quesitos y lista de todo lo que ocupa sitio en + la consola, con el nombre de cada juego y su tamaño. Marca juegos repetidos + (por ejemplo la carpeta y su versión convertida), envíos a medias y restos, + y se pueden borrar desde ahí. También con discos USB/externos. +- Descargas más rápidas: cada conexión se reutiliza para muchos trozos + seguidos, como hacen los gestores de descargas. +- La extensión pasa a PS5 Directo las cookies de ese alojamiento, el navegador + y la página de origen: misma velocidad y permiso que en el navegador (sesión + iniciada, cuenta premium) y menos errores 403. Las cookies no salen de tu PC + ni se guardan en disco. Tras actualizar, en chrome://extensions recarga la + extensión y acepta el permiso nuevo. +- Carpetas: antes de enviar comprueba que lo que falta cabe en la PS5, y la + velocidad ya no da saltos falsos al continuar un archivo a medias. +- La IP de la PS5 se limpia sola si se escribe con espacios, ftp://, puerto o + comas (antes daba "getaddrinfo failed"). +- Tras actualizar, una ventana te enseña las novedades. +- Mejor diseño en ventanas estrechas y pequeños arreglos. + NOVEDADES 1.17 - Enlaces ftp:// (un NAS u otro servidor): ftp://usuario:clave@nas/ruta/archivo.rar Funcionan como los enlaces normales, con reanudación. diff --git a/README.md b/README.md index 8e26cc0..149e1f7 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,12 @@ A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the - ✅ Fixes the file permissions that stop BFpilot-extracted content from launching (CE-107750-0) - ✅ Pause, resume and retry where it left off, even after a dropped connection - ✅ Built-in console file browser: sizes, copy, move, rename, delete, download to PC, upload +- ✅ Storage view: a pie chart of what fills the console, by game name and size, flagging duplicates and leftovers you can delete - ✅ Send whole PC folders over several connections, skipping what's already there and resuming what got cut off - ✅ Watches free space so the console never fills up - ✅ Speed limit and built-in speed test -- ✅ Optional browser extension: links arrive by themselves, no copy and paste +- ✅ Download-manager style downloads: several reused connections per file, auto-backoff when a host complains +- ✅ Optional browser extension: links arrive by themselves with your browser session (same speed and access as the browser) - ✅ Drag and drop, or "Send to" from Explorer - ✅ Welcome guide, one-click updates, English and Spanish diff --git a/README_EN.txt b/README_EN.txt index 49c6f7d..99ce1f1 100644 --- a/README_EN.txt +++ b/README_EN.txt @@ -1,4 +1,4 @@ -PS5 DIRECTO 1.17 — USER GUIDE (English) +PS5 DIRECTO 1.18 — USER GUIDE (English) ======================================= Paste links (or drag files from your PC) and the tool does the rest: @@ -25,6 +25,11 @@ TABS the current folder. Copy, move, delete and folder sizes need BFpilot; without it the browser is read-only over FTP. + - Space: a pie chart of what takes up space on the console, with each + game's name and size. It flags repeated games (for example a folder and + its version converted to an image), half-sent transfers and leftovers, + and lets you delete them from there (it asks first). Needs BFpilot. + Games installed from the PS5 itself are shown but deleted on the console. - Activity: history of finished and failed transfers. PC FOLDERS @@ -215,6 +220,23 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo WHAT'S NEW ---------- +1.18 - "Space" tab: a pie chart and list of everything that takes up space + on the console, by game name and size. It flags repeated games (for + example a folder and its converted version), half-sent transfers and + leftovers, and deletes them from there. USB/external drives too. + - Faster downloads: each connection is reused for many chunks in a row, + like download managers do. + - The extension passes that host's cookies, browser and source page to + PS5 Directo: same speed and access as in the browser (logged-in or + premium) and fewer 403 errors. Cookies never leave your PC and are + never saved to disk. After updating, reload the extension in + chrome://extensions and accept the new permission. + - Folders: checks that what's left to send fits on the PS5, and the + speed no longer shows false jumps when resuming a half-sent file. + - The PS5 IP is cleaned up if typed with spaces, ftp://, a port or + commas (it used to fail with "getaddrinfo failed"). + - After updating, a window shows what's new. + - Better layout on narrow windows and small fixes. 1.17 ftp:// links (a NAS or other server) with resume. .tar/.tar.gz/.tar.bz2/ .tar.xz extracted on the PC (no 7-Zip needed). Folders upload over several connections at once. Own steps for folder uploads. diff --git a/app.py b/app.py index ec1e790..e235c98 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,7 @@ import sys import threading import time import traceback +import urllib.parse import urllib.request import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -21,7 +22,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import engine as E # noqa: E402 -VERSION = "1.17.0" +VERSION = "1.18.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" @@ -53,6 +54,7 @@ DEFAULTS = { "guess_passwords": True, "delete_archives": True, "tour_done": False, # la guia de bienvenida ya se vio + "seen_version": "", # ultima version cuyas novedades ya se ensenaron } LOCK = threading.RLock() @@ -824,7 +826,155 @@ print(json.dumps([os.path.normpath(x) for x in res])) ''' +# --------------------------------------------------------------------------- +# Pestana Espacio: que ocupa sitio en la consola (grafico de quesitos + lista) +# --------------------------------------------------------------------------- + +SPACE = {"busy": False, "root": "", "done": 0, "total": 0, "current": "", "result": None, "error": ""} +SPACE_CANCEL = threading.Event() +IMAGE_RE = re.compile(r"\.(exfat|ffpkg|ffpfsc?|ufs|img)$", re.I) +EXPAND = ("homebrew", "pkg") # carpetas cuyo contenido se ensena elemento a elemento + + +def title_name(text): + """Nombre del juego/app en un param.json (o None).""" + try: + j = json.loads(text) + except Exception: + m = re.search(r'"titleName"\s*:\s*"([^"]+)"', text or "") + return m.group(1) if m else None + lp = j.get("localizedParameters") or {} + for lang in (lp.get("defaultLanguage"), "es-ES", "en-US", "en-GB"): + if lang and isinstance(lp.get(lang), dict) and lp[lang].get("titleName"): + return lp[lang]["titleName"] + for v in lp.values(): + if isinstance(v, dict) and v.get("titleName"): + return v["titleName"] + return j.get("titleName") + + +def space_scan(root): + cfg = cfg_snapshot() + bf = E.BFpilot(cfg) + if not bf.alive(): + raise E.PipeError("Para analizar el espacio hace falta BFpilot cargado en la PS5") + root = fm_clean(root or "/data") + st = bf._req("GET", "/api/fs/stat", {"path": root}) + total = int(st.get("usableTotalBytes") or st.get("totalBytes") or 0) + free = int(st.get("availableBytes") or 0) + work = [] # (ruta, nombre, es_carpeta, tamano_listado, grupo) + for e in bf.listdir(root): + path = "%s/%s" % (root.rstrip("/"), e["name"]) + if e.get("dir") and e["name"].lower() in EXPAND: + for c in bf.listdir(path): + work.append(("%s/%s" % (path, c["name"]), c["name"], bool(c.get("dir")), c.get("size") or 0, + e["name"].lower())) + else: + work.append((path, e["name"], bool(e.get("dir")), e.get("size") or 0, "")) + extra = [] + if root == "/data": + extra.append(("/user/app", "Juegos instalados desde la PS5", True, 0, "system")) + with LOCK: + SPACE.update(total=len(work) + len(extra), done=0) + items = [] + for i, (path, name, is_dir, lsize, group) in enumerate(work + extra): + if SPACE_CANCEL.is_set(): + raise E.Cancelled() + with LOCK: + SPACE.update(done=i, current=path) + size = bf.du(path, timeout=300) if is_dir else int(lsize or 0) + if size is None: + size = int(lsize or 0) + it = {"path": path, "name": name, "size": size, "dir": is_dir, "group": group, + "kind": "folder" if is_dir else "file", "title": None, "tid": None, + "deletable": True, "notes": []} + if group == "system": + it.update(kind="system", deletable=False) + it["notes"].append("Bórralos desde la consola: Ajustes > Almacenamiento") + elif group == "homebrew": + if is_dir and bf.exists(path + "/sce_sys/param.json"): + txt = bf.read_text(path + "/sce_sys/param.json") + it.update(kind="app", title=title_name(txt), tid=E.title_id(txt) or E.title_id(name)) + elif IMAGE_RE.search(name): + it.update(kind="image", tid=E.title_id(name)) + elif is_dir: + it["kind"] = "incomplete" + it["notes"].append("No tiene sce_sys: no es una app completa (¿envío cortado o copia a medias?)") + elif group == "pkg": + it["kind"] = "pkg" + it["notes"].append("Instalador .pkg: si ya está instalado, se puede borrar") + elif root == "/data" and name == "ps5directo": + it["kind"] = "leftover" + it["notes"].append("Restos de envíos de PS5 Directo") + elif root == "/data" and is_dir: + it["kind"] = "data" + it["notes"].append("Datos de payloads o ajustes: bórralo solo si sabes qué es") + if re.search(r"\.(ps5part|ava-part|part|tmp)$", name, re.I): + it["kind"] = "leftover" + it["notes"].append("Archivo a medias de un envío") + if is_dir and 0 < size < 1024 * 1024 and group == "homebrew": + it["notes"].append("Casi vacía (%s)" % human(size)) + items.append(it) + # el mismo juego dos veces (por ejemplo la carpeta y la imagen convertida a lz4) + by_tid = {} + for it in items: + if it["tid"]: + by_tid.setdefault(it["tid"].upper(), []).append(it) + for tid, lst in by_tid.items(): + if len(lst) > 1: + for it in lst: + others = [x["name"] for x in lst if x is not it] + it["dup"] = True + it["notes"].append("Duplicado: %s también está en %s" % (tid, ", ".join(others[:3]))) + names = {it["tid"].upper(): it["title"] for it in items if it["tid"] and it["title"]} + for it in items: + if not it["title"] and it["tid"] and it["tid"].upper() in names: + it["title"] = names[it["tid"].upper()] + items.sort(key=lambda x: -x["size"]) + used = max(0, total - free) + known = sum(x["size"] for x in items) + if extra and known > used: # /user/app no comparte particion: no lo contamos + items = [x for x in items if x["kind"] != "system"] + known = sum(x["size"] for x in items) + return {"root": root, "total": total, "free": free, "used": used, + "other": max(0, used - known), "items": items, "t": time.time()} + + +def space_start(root): + with LOCK: + if SPACE["busy"]: + raise E.PipeError("Ya se está analizando") + SPACE.update(busy=True, root=root, done=0, total=0, current="", error="") + SPACE_CANCEL.clear() + + def run(): + try: + res = space_scan(root) + with LOCK: + SPACE["result"] = res + except E.Cancelled: + with LOCK: + SPACE["error"] = "Cancelado" + except Exception as e: + traceback.print_exc() + with LOCK: + SPACE["error"] = str(e) + finally: + with LOCK: + SPACE["busy"] = False + threading.Thread(target=run, daemon=True).start() + + def fm_api(p, b): + if p == "/api/space/scan": + space_start(str(b.get("root") or "/data")) + return {} + if p == "/api/space/state": + with LOCK: + return {"space": dict(SPACE)} + if p == "/api/space/cancel": + SPACE_CANCEL.set() + return {} if p == "/api/fm/list": return fm_list(b.get("path") or "/data") if p == "/api/fm/du": @@ -948,9 +1098,16 @@ def folder_job(job, src, cancel): setj(job, sent=v, phase="Enviando carpeta: %s" % rel[-80:]) setj(job, status="enviando", total=total, sent=0, phase="Comparando con lo que ya hay en la PS5") + def plan(need): + jlog(job, "Falta por enviar: %s" % human(need)) + if free is not None and need > 0 and free - need < margin_bytes(): + raise E.PipeError("No cabe en la PS5: faltan %s por enviar, hay %s libres y el margen es %s. " + "Libera espacio y pulsa Reintentar." % (human(need), human(free), human(margin_bytes()))) + watch = start_space_watch(job, bf, cancel, "el envio") try: - sent, skipped, resumed = E.upload_tree(cfg, src, target, cancel, lambda m: jlog(job, m), progress) + sent, skipped, resumed = E.upload_tree(cfg, src, target, cancel, lambda m: jlog(job, m), progress, + on_plan=plan) finally: stop_speed.set() watch.set() @@ -2057,8 +2214,10 @@ def run_speedtest(url, seconds=10): ideal = 1 / (1 / dl + 1 / up) pipe = min(both, res["both_dl"]) # lo que llegaria de verdad descargando y enviando res["pipe"] = pipe - v.append("Las dos cosas a la vez: en la practica llegarian unos %.1f MB/s a la PS5. Con una " - "sola radio Wi-Fi lo maximo esperable seria unos %.1f MB/s." % (pipe / mb, ideal / mb)) + v.append("Las dos cosas a la vez: en la practica llegarian unos %.1f MB/s a la PS5." % (pipe / mb)) + if dl >= 5 * mb: + # solo tiene sentido cuando el enlace no es el cuello de botella + v.append("Con una sola radio Wi-Fi lo maximo esperable seria unos %.1f MB/s." % (ideal / mb)) if dl < 5 * mb: v.append("Aqui manda el alojamiento: mientras de tan poca velocidad, nada de tu lado " "lo va a mejorar.") @@ -2157,6 +2316,90 @@ def _ver_tuple(v): return tuple(int(x) for x in re.findall(r"\d+", v or "0")[:4]) +def _changelog(lang): + """{version: [novedades]} sacado de LEEME.txt (es) o README_EN.txt (en).""" + fn = "README_EN.txt" if lang == "en" else "LEEME.txt" + try: + with open(os.path.join(HERE, fn), encoding="utf-8") as f: + lines = f.read().splitlines() + except Exception: + return {} + out, cur, item = {}, None, None + in_en = False + for ln in lines: + if lang == "en": + if ln.startswith("WHAT'S NEW"): + in_en = True + continue + if not in_en: + continue + if ln.strip() == "" and cur: + break + m = re.match(r"^(\d+\.\d+(?:\.\d+)?)\s+(.*)$", ln) + if m: + cur = m.group(1) + out[cur] = [] + rest = m.group(2).strip() + item = None + ln = " " + rest + else: + m = re.match(r"^NOVEDADES (\d+\.\d+(?:\.\d+)?)\s*$", ln) + if m: + cur = m.group(1) + out[cur] = [] + item = None + continue + if cur and ln.strip() == "": + cur = None + continue + if not cur: + continue + t = ln.strip() + if t.startswith("- "): + out[cur].append(t[2:]) + elif t and out[cur]: + out[cur][-1] += " " + t + elif t: + out[cur].append(t) + return out + + +def whats_new(lang): + """Novedades que ensenar tras actualizar (vacio si no toca).""" + with LOCK: + seen = SETTINGS.get("seen_version") or "" + tour_done = SETTINGS.get("tour_done") + cur = _ver_tuple(VERSION) + if seen and _ver_tuple(seen) >= cur: + return {"show": False} + if not seen: + if not tour_done: # instalacion nueva: ya ve la guia de bienvenida + with LOCK: + SETTINGS["seen_version"] = VERSION + save_settings() + return {"show": False} + # venia de una version sin este aviso: la anterior es la copia en .backup + prev = None + try: + for d in os.listdir(os.path.join(HERE, ".backup")): + if re.match(r"^\d+(\.\d+)+$", d) and _ver_tuple(d) < cur: + if prev is None or _ver_tuple(d) > _ver_tuple(prev): + prev = d + except Exception: + pass + seen = prev + log = _changelog(lang) + pad = lambda t: (t + (0, 0, 0))[:3] + entries = [] + for v, items in log.items(): + vt = pad(_ver_tuple(v)) + if vt <= pad(cur) and (seen is None and vt[:2] == pad(cur)[:2] or seen is not None and vt > pad(_ver_tuple(seen))): + if items: + entries.append({"version": v, "items": items}) + entries.sort(key=lambda e: _ver_tuple(e["version"]), reverse=True) + return {"show": bool(entries), "version": VERSION, "entries": entries[:6]} + + def install_zip(data): """Instala una version nueva desde los bytes de un .zip. Devuelve la version instalada.""" import io @@ -2331,6 +2574,14 @@ class Handler(BaseHTTPRequestHandler): url = str(b.get("url") or "").strip() if not re.match(r"^https?://", url, re.I) or len(url) > 4000: return self._send(400, {"ok": False, "error": "enlace no valido"}) + # cookies y navegador que usa ese alojamiento: el servidor nos dara la misma + # velocidad y el mismo permiso que al navegador (sesion, cuenta premium) + cookies = b.get("cookies") if isinstance(b.get("cookies"), dict) else {} + for u in (url, str(b.get("original") or "")): + if re.match(r"^https?://", u, re.I): + host = urllib.parse.urlparse(u).netloc.lower() + E.set_browser_headers(u, str(cookies.get(host) or ""), str(b.get("ua") or ""), + str(b.get("referrer") or "")) with LOCK: if not any(x["url"] == url for x in INBOX): INBOX.append({"url": url, "name": str(b.get("name") or "")[:300], "t": time.time()}) @@ -2381,6 +2632,13 @@ class Handler(BaseHTTPRequestHandler): return self._send(400, {"ok": False, "error": str(e)}) restart_soon() return self._send(200, {"ok": True, "version": ver}) + if p == "/api/whatsnew": + return self._send(200, dict(ok=True, **whats_new("en" if b.get("lang") == "en" else "es"))) + if p == "/api/whatsnew/seen": + with LOCK: + SETTINGS["seen_version"] = VERSION + save_settings() + return self._send(200, {"ok": True}) if p == "/api/settings": with LOCK: for k, v in b.items(): @@ -2394,6 +2652,13 @@ class Handler(BaseHTTPRequestHandler): continue elif isinstance(DEFAULTS[k], list): v = [x.strip() for x in (v or []) if str(x).strip()] + if k == "ps5_ip": + # "ftp://192.168.1.50:2121", con espacios o comas: dejar solo la IP + v, port = E.clean_host(v) + if port and port != int(b.get("bf_port") or SETTINGS.get("bf_port", 5905)): + SETTINGS["ftp_port"] = port + if "ftp_port" in b: + b["ftp_port"] = port SETTINGS[k] = v save_settings() return self._send(200, {"ok": True}) @@ -2409,7 +2674,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 in ("/api/pick", "/api/history", "/api/history/clear"): + if p.startswith("/api/fm/") or p.startswith("/api/space/") or p in ("/api/pick", "/api/history", "/api/history/clear"): try: return self._send(200, dict(ok=True, **fm_api(p, b))) except E.PipeError as e: diff --git a/engine.py b/engine.py index afe6844..c237072 100644 --- a/engine.py +++ b/engine.py @@ -10,6 +10,7 @@ Solo usa la biblioteca estandar de Python 3.9+. """ import ftplib +import http.client import json import math import os @@ -158,20 +159,125 @@ HONEST_UA = "PS5Directo/1.0 (download manager)" _HOST_MODE = {} -def _open(url, headers=None, timeout=30): +# Lo que la extension del navegador nos cuenta de cada alojamiento: sus cookies (sesion, +# cuenta premium), el navegador y la pagina de origen. Asi el servidor nos trata igual que +# al navegador, como hacen los gestores de descargas. Solo en memoria, nunca en disco. +_BROWSER = {} +_BROWSER_LOCK = threading.Lock() + + +def set_browser_headers(url, cookie="", ua="", referer=""): + host = urllib.parse.urlparse(url).netloc.lower() + if not host: + return + info = {} + if cookie: + info["Cookie"] = cookie[:8000] + if ua: + info["User-Agent"] = ua[:500] + if referer and re.match(r"^https?://", referer, re.I): + info["Referer"] = referer[:2000] + with _BROWSER_LOCK: + if info: + _BROWSER[host] = info + else: + _BROWSER.pop(host, None) + + +def _headers(url, extra=None): pu = urllib.parse.urlparse(url) - if _HOST_MODE.get(pu.netloc) == "honest": + host = pu.netloc.lower() + with _BROWSER_LOCK: + browser = dict(_BROWSER.get(host) or {}) + if _HOST_MODE.get(pu.netloc) == "honest" and not browser: h = {"User-Agent": HONEST_UA, "Accept": "*/*"} else: h = {"User-Agent": UA, "Accept": "*/*", "Accept-Language": "es-ES,es;q=0.9,en;q=0.8"} if pu.scheme in ("http", "https") and pu.netloc: h["Referer"] = "%s://%s/" % (pu.scheme, pu.netloc) - if headers: - h.update(headers) - req = urllib.request.Request(url, headers=h) + h.update(browser) + if extra: + h.update(extra) + return h + + +def _open(url, headers=None, timeout=30): + req = urllib.request.Request(url, headers=_headers(url, headers)) return urllib.request.urlopen(req, timeout=timeout, context=_SSL) +class _KeepAlive: + """Una conexion HTTP que se reutiliza para muchos trozos seguidos (como los gestores + de descargas): sin repetir el saludo TCP/TLS ni el arranque lento de TCP en cada trozo. + Si hay proxy o el servidor redirige, se usa la via normal.""" + + def __init__(self): + self.conn = None + self.key = None + self.used = False + self.off = False + + @staticmethod + def usable(url): + scheme = urllib.parse.urlparse(url).scheme.lower() + if scheme not in ("http", "https"): + return False + try: + return not urllib.request.getproxies().get(scheme) + except Exception: + return False + + def open(self, url, headers, timeout): + if self.off: + return None + pu = urllib.parse.urlparse(url) + key = (pu.scheme.lower(), pu.netloc) + if self.conn is None or self.key != key: + self.close() + if key[0] == "https": + self.conn = http.client.HTTPSConnection(pu.netloc, timeout=timeout, context=_SSL) + else: + self.conn = http.client.HTTPConnection(pu.netloc, timeout=timeout) + self.key = key + path = pu.path or "/" + if pu.query: + path += "?" + pu.query + h = _headers(url, headers) + h["Connection"] = "keep-alive" + try: + self.conn.request("GET", path, headers=h) + r = self.conn.getresponse() + except Exception: + stale = self.used # el servidor cerro la conexion mientras esperaba: + self.close() # es normal, se abre otra sin contarlo como fallo + if not stale: + raise + return self.open(url, headers, timeout) + self.used = True + if r.status >= 300: + hdrs = r.headers + try: + r.read(64 * 1024) + except Exception: + pass + self.close() + if r.status < 400: + self.off = True # redirige en cada trozo: que lo lleve urllib + return None + raise urllib.error.HTTPError(url, r.status, r.reason, hdrs, None) + return r + + def close(self): + if self.conn is not None: + try: + self.conn.close() + except Exception: + pass + self.conn = None + self.key = None + self.used = False + + def resolve_url(url): """Convierte paginas de algunos alojamientos en el enlace directo (lo que se pueda).""" url = url.strip() @@ -289,15 +395,20 @@ def _single(url, cancel, meter): yield data -def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None): +def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None, ka=None): want = b - a + 1 last = None + rng = {"Range": "bytes=%d-%d" % (a, b)} for attempt in range(tries): if cancel.is_set(): raise Cancelled() got = bytearray() try: - with _open(url, {"Range": "bytes=%d-%d" % (a, b)}, timeout=25) as r: + r = ka.open(url, rng, 25) if ka is not None else None + reused = r is not None + if r is None: + r = _open(url, rng, timeout=25) + try: if r.status != 206: raise PipeError("el servidor no respeto el rango pedido") while len(got) < want: @@ -308,8 +419,19 @@ def _fetch_range(url, a, b, cancel, meter, tries=6, on_trouble=None): break got += piece meter(len(piece)) + except BaseException: + if reused: + ka.close() + raise + finally: + if not reused: + r.close() if len(got) == want: + if reused and r.will_close: + ka.close() # el servidor no deja reutilizarla return bytes(got) + if reused: + ka.close() last = "respuesta corta (%d de %d bytes)" % (len(got), want) except (Cancelled, PipeError): raise @@ -392,7 +514,17 @@ def _ranged(url, start, end, cfg, cancel, meter): state["trouble_t"] = now log("El servidor va con problemas (%s): bajo a %d conexiones" % (kind, state["allowed"])) + use_ka = _KeepAlive.usable(url) + def worker(): + ka = _KeepAlive() if use_ka else None + try: + _worker(ka) + finally: + if ka is not None: + ka.close() + + def _worker(ka): while True: with cond: # recuperar conexiones si lleva un rato sin problemas @@ -415,18 +547,18 @@ def _ranged(url, start, end, cfg, cancel, meter): started[i] = time.time() state["active"] += 1 try: - fetch(i) + fetch(i, ka=ka) finally: with cond: state["active"] -= 1 cond.notify_all() - def fetch(i, hedge=False): + def fetch(i, hedge=False, ka=None): a = start + i * chunk b = min(end, a + chunk) - 1 t0 = time.time() try: - data = _fetch_range(url, a, b, cancel, meter, on_trouble=trouble) + data = _fetch_range(url, a, b, cancel, meter, on_trouble=trouble, ka=ka) except Exception as e: with cond: # si es la copia de rescate, o el original ya llego, no es grave @@ -669,10 +801,12 @@ def _ftp_sizes(f, path): return out -def upload_tree(cfg, src, remote_root, cancel, log, progress, workers=None): +def upload_tree(cfg, src, remote_root, cancel, log, progress, workers=None, on_plan=None): """Manda una FolderSource a remote_root. Salta los archivos que ya estan con el mismo tamano, continua los que se cortaron (mas pequenos) y vuelve a mandar los demas. Usa varias conexiones a la vez (cfg parallel_files). progress(bytes_hechos, ruta_actual). + on_plan(bytes_que_faltan), si se da, se llama tras comparar y antes de enviar nada + (para comprobar el espacio libre); puede lanzar una excepcion para no seguir. Devuelve (enviados, saltados, continuados).""" import queue by_dir = {} @@ -700,9 +834,12 @@ def upload_tree(cfg, src, remote_root, cancel, log, progress, workers=None): st["done"] += size continue offset = got if (got is not None and 0 < got < size) else 0 + st["done"] += offset # lo que ya habia en la consola cuenta como hecho tasks.put((rdir + "/" + name, rel, size, offset)) finally: w.close() + if on_plan is not None: + on_plan(max(0, src.size - st["done"])) progress(st["done"], "") if tasks.empty(): return 0, st["skipped"], 0 @@ -724,8 +861,6 @@ def upload_tree(cfg, src, remote_root, cancel, log, progress, workers=None): 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) @@ -817,9 +952,24 @@ def concat_stream(parts, offset, cfg, cancel, meter): # FTP de la PS5 # --------------------------------------------------------------------------- +def clean_host(text): + """Deja solo la IP/nombre de la PS5 aunque se escriba con espacios, ftp://, puerto o + comas: ' ftp://192,168,1,50:2121/ ' -> ('192.168.1.50', 2121).""" + t = re.sub(r"[\s\u200b\u00a0\ufeff]+", "", str(text or "")) + t = re.sub(r"^[a-z][a-z0-9+.-]*://", "", t, flags=re.I) + t = t.split("/", 1)[0].split("@")[-1] + port = None + m = re.match(r"^(.*?):(\d{1,5})$", t) + if m and m.group(1).count(":") == 0: + t, port = m.group(1), int(m.group(2)) + if re.fullmatch(r"[\d,.]+", t): + t = t.replace(",", ".") + return t.strip(".").strip("[]"), port + + class Ftp: def __init__(self, cfg): - self.host = cfg["ps5_ip"] + self.host = clean_host(cfg["ps5_ip"])[0] self.port = int(cfg.get("ftp_port", 2121)) self.user = cfg.get("ftp_user") or "anonymous" self.pw = cfg.get("ftp_pass") or "" @@ -829,7 +979,11 @@ class Ftp: raise PipeError("Falta la IP de la PS5: ponla en Ajustes") f = ftplib.FTP() f.encoding = "utf-8" - f.connect(self.host, self.port, timeout=30) + try: + f.connect(self.host, self.port, timeout=30) + except socket.gaierror: + raise PipeError("La IP de la PS5 no es valida (%s): en Ajustes escribe solo los numeros, " + "por ejemplo 192.168.1.50, sin ftp:// ni puerto" % self.host) f.login(self.user, self.pw) f.set_pasv(True) f.voidcmd("TYPE I") @@ -980,7 +1134,7 @@ def upload(cfg, remote_final, total, make_iter, progress, cancel, log, resume=Tr class BFpilot: def __init__(self, cfg): - self.base = "http://%s:%s" % (cfg["ps5_ip"], int(cfg.get("bf_port", 5905))) + self.base = "http://%s:%s" % (clean_host(cfg["ps5_ip"])[0], int(cfg.get("bf_port", 5905))) self.cfg = cfg def _req(self, method, path, params=None, form=None, timeout=20): @@ -1130,10 +1284,10 @@ class BFpilot: raise self.wait_job(cancel) - def du(self, path): + def du(self, path, timeout=20): """Tamano total de una carpeta (o archivo), o None.""" try: - return int(self._req("GET", "/api/fs/du", {"path": path}).get("size") or 0) + return int(self._req("GET", "/api/fs/du", {"path": path}, timeout=timeout).get("size") or 0) except Exception: return None @@ -1348,7 +1502,7 @@ class _FtpWriter: else: c = self.f.transfercmd("STOR " + path) try: - c.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2 * MiB) + c.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * MiB) except Exception: pass return c diff --git a/extension/bg.js b/extension/bg.js index 045c53b..66b6e7e 100644 --- a/extension/bg.js +++ b/extension/bg.js @@ -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; } diff --git a/extension/manifest.json b/extension/manifest.json index ed55e58..b10ffcb 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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/*", + "" + ], + "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" + } +} \ No newline at end of file diff --git a/web/index.html b/web/index.html index 44c0472..90f71ba 100644 --- a/web/index.html +++ b/web/index.html @@ -20,7 +20,7 @@ header .sub{color:var(--muted);font-size:13px} .pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);font-size:12.5px;color:var(--muted);white-space:nowrap} .dot{width:8px;height:8px;border-radius:50%;background:var(--muted)} .dot.ok{background:var(--ok)}.dot.err{background:var(--err)}.dot.warn{background:var(--warn)} -main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;gap:18px} +main{max-width:1060px;margin:0 auto;padding:22px 20px 60px;display:grid;grid-template-columns:minmax(0,1fr);gap:18px} .card{background:rgba(22,27,34,.86);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:var(--radius);padding:18px} .card h2{font-size:15px;margin:0 0 12px;font-weight:600} textarea,input,select{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font:inherit} @@ -69,6 +69,8 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto} .tabs button{background:transparent;border:1px solid transparent;border-radius:9px;padding:8px 14px;color:var(--muted);font-weight:600} .tabs button:hover{color:var(--text);border-color:var(--line)} .tabs button.on{color:var(--text);background:var(--panel2);border-color:var(--line)} +.tabs{overflow-x:auto;scrollbar-width:none}.tabs button{flex:none} +@media(max-width:600px){.tabs{padding:0 12px;gap:2px}.tabs button{padding:7px 9px;font-size:13.5px}} .pickrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} /* --- explorador --- */ .fm-head{display:flex;align-items:center;gap:16px;flex-wrap:wrap} @@ -76,6 +78,7 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto} .store{min-width:220px;text-align:right;font-size:13px;color:var(--muted)} .store b{color:var(--text)} .store .bar{margin-top:6px;height:6px} +@media(max-width:600px){.store{min-width:0;flex:1 1 100%;text-align:left}#tabFiles .fmtable{display:block;overflow-x:auto}} .fm-bar{display:flex;gap:6px;align-items:center;margin-top:14px;flex-wrap:wrap} .fm-bar input,.fm-bar select{width:auto} .fm-bar #fmPath{flex:1;min-width:220px;font-family:Consolas,"Cascadia Mono",monospace;font-size:13px} @@ -102,6 +105,37 @@ dialog .body{padding:4px 18px 18px;max-height:70vh;overflow:auto} .rowact{display:flex;gap:4px;justify-content:flex-end;opacity:.5} tr:hover .rowact{opacity:1} .rowact button{padding:3px 8px;font-size:12px} +.news-v{margin:14px 0 6px;font-size:14px;color:var(--accent)} +.news-list{margin:0;padding-left:20px;line-height:1.5}.news-list li{margin:6px 0} +.sp-grid{display:grid;grid-template-columns:280px 1fr;gap:22px;align-items:center;margin-top:14px} +@media(max-width:760px){.sp-grid{grid-template-columns:1fr}.sp-donut{margin:0 auto}} +.sp-donut{position:relative;width:280px;height:280px} +.sp-donut svg{display:block} +.sp-donut path{cursor:pointer;transition:opacity .15s} +.sp-donut.hl path:not(.on){opacity:.35} +.sp-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;pointer-events:none;padding:0 70px} +.sp-center b{font-size:22px}.sp-center span{color:var(--muted);font-size:12.5px} +.sp-legend{display:flex;flex-direction:column;gap:5px;font-size:13.5px} +.sp-legend .row{display:flex;align-items:center;gap:9px;padding:4px 6px;border-radius:7px;cursor:pointer} +.sp-legend .row:hover,.sp-legend .row.on{background:rgba(255,255,255,.05)} +.sp-legend .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sp-legend .sz{color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums} +.sw{width:12px;height:12px;border-radius:3px;flex:none;display:inline-block} +.sw.free{background:transparent;border:1.5px dashed #5b6573} +.sp-note{font-size:12px;color:var(--muted);margin-top:2px} +.sp-note.dup,.sp-note.bad{color:var(--warn)} +.sp-pct{height:5px;background:var(--bg);border-radius:4px;overflow:hidden;margin-top:4px;max-width:180px} +.sp-pct>i{display:block;height:100%;border-radius:4px} +.sp-tip{position:fixed;z-index:50;pointer-events:none;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12.5px;box-shadow:0 6px 20px rgba(0,0,0,.4);max-width:320px} +.sp-tools{display:flex;gap:8px;flex-wrap:wrap;align-items:center} +.sp-tools select{width:auto} +#spBody .mono{word-break:break-all} +.sp-grid>*{min-width:0} +#spListCard table{table-layout:fixed} +#spListCard th:nth-child(1){width:28px}#spListCard th:nth-child(3){width:90px}#spListCard th:nth-child(4){width:150px} +@media(max-width:600px){#spListCard th:nth-child(4){width:76px}#spListCard th:nth-child(3){width:72px}} +#spBody td{overflow-wrap:anywhere} +@media(max-width:600px){#spBody td:last-child button{display:block;width:100%;margin:3px 0}#spBody td:last-child{white-space:normal}} .acttable{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:10px} .acttable th{color:var(--muted);font-weight:600;font-size:12px;text-align:left;padding:8px;border-bottom:1px solid var(--line)} .acttable td{padding:8px;border-bottom:1px solid rgba(42,50,61,.6);vertical-align:top} @@ -151,6 +185,7 @@ tr:hover .rowact{opacity:1} @@ -158,7 +193,7 @@ tr:hover .rowact{opacity:1}

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.
@@ -245,6 +280,24 @@ tr:hover .rowact{opacity:1}
+
+
+

Espacio

+
+
+

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.

+ +
+
+ +
+

Actividad

@@ -254,6 +307,12 @@ tr:hover .rowact{opacity:1}
+ +

Novedades

+
+
+
+

Test de velocidad

@@ -344,8 +403,8 @@ tr:hover .rowact{opacity:1}