mirror of
https://github.com/jstrdev/ps5directo.git
synced 2026-10-29 00:01:20 +01:00
3012 lines
117 KiB
Python
3012 lines
117 KiB
Python
"""PS5 Directo: transfiere archivos a la consola, los descomprime y los deja en su sitio.
|
|
|
|
Arranque: python app.py (o doble clic en "Iniciar PS5 Directo.bat")
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import os
|
|
import posixpath
|
|
import secrets
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import urllib.parse
|
|
import urllib.request
|
|
import webbrowser
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import engine as E # noqa: E402
|
|
|
|
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"
|
|
DATA_DIR = os.path.join(os.path.expanduser("~"), ".ps5directo")
|
|
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
|
JOBS_FILE = os.path.join(DATA_DIR, "jobs.json")
|
|
WORK_ROOT = "/data/ps5directo" # fuera de las rutas que escanea ShadowMountPlus
|
|
|
|
DEFAULTS = {
|
|
"ps5_ip": "",
|
|
"ftp_port": 2121,
|
|
"ftp_user": "anonymous",
|
|
"ftp_pass": "",
|
|
"bf_port": 5905,
|
|
"dest": "/data/homebrew",
|
|
"streams": 8,
|
|
"parallel_files": 2,
|
|
"limit_mbps": 0, # MB/s maximos hacia la PS5 (0 = sin limite)
|
|
"space_margin_gb": 25, # GB que siempre se dejan libres en la PS5
|
|
"update_repo": "", # "usuario/repositorio" de GitHub para buscar actualizaciones
|
|
"extract_threads": 0, # hilos de BFpilot al descomprimir (0 = automatico, max 8)
|
|
"extract_where": "ps5", # "ps5" = BFpilot en la consola; "pc" = 7-Zip en el PC sobre la marcha
|
|
"pc_temp_dir": "", # carpeta del PC donde bajar los RAR en modo "pc" (vacio = Descargas)
|
|
"sevenzip_path": "", # ruta de 7z.exe (vacio = buscarlo)
|
|
"chunk_mb": 4,
|
|
"buffer_mb": 256,
|
|
"retries": 8,
|
|
"passwords": [],
|
|
"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()
|
|
SETTINGS = dict(DEFAULTS)
|
|
JOBS = [] # lista de dicts (orden de cola)
|
|
CANCELS = {} # id -> threading.Event
|
|
WAKE = threading.Condition(LOCK)
|
|
EXTRACT_Q = [] # ids pendientes de extraer
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Persistencia
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _save_json(path, obj):
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump(obj, f, ensure_ascii=False, indent=1)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def load_state():
|
|
global JOBS
|
|
try:
|
|
with open(SETTINGS_FILE, encoding="utf-8") as f:
|
|
SETTINGS.update(json.load(f))
|
|
except Exception:
|
|
pass
|
|
# 1.8.1: la 1.8.0 puso 1 hilo por error; volver a automatico una sola vez
|
|
if SETTINGS.get("_threads_v18") and not SETTINGS.get("_threads_v181"):
|
|
if int(SETTINGS.get("extract_threads") or 0) == 1:
|
|
SETTINGS["extract_threads"] = 0
|
|
SETTINGS["_threads_v181"] = True
|
|
try:
|
|
with open(JOBS_FILE, encoding="utf-8") as f:
|
|
JOBS = json.load(f)
|
|
except Exception:
|
|
JOBS = []
|
|
for j in JOBS:
|
|
if j["status"] not in ("hecho", "error", "cancelado"):
|
|
j["status"] = "error"
|
|
j["error"] = "Se cerro el programa a medias. Pulsa Reintentar: continua donde se quedo."
|
|
|
|
|
|
def save_jobs():
|
|
with LOCK:
|
|
_save_json(JOBS_FILE, JOBS)
|
|
|
|
|
|
def save_settings():
|
|
with LOCK:
|
|
_save_json(SETTINGS_FILE, SETTINGS)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utilidades de trabajos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def new_job(lines, password, dest, title=None, where=None, raw=False):
|
|
return {
|
|
"raw": bool(raw),
|
|
"where": where if where in ("ps5", "pc") else "",
|
|
"id": secrets.token_hex(4),
|
|
"created": time.time(),
|
|
"title": title or "",
|
|
"lines": lines,
|
|
"sources": [],
|
|
"password": password or "",
|
|
"dest": dest,
|
|
"status": "pendiente",
|
|
"phase": "En cola",
|
|
"sent": 0, "total": -1, "speed": 0.0,
|
|
"extract_pct": 0, "extract_text": "",
|
|
"error": "", "result": [], "log": [],
|
|
}
|
|
|
|
|
|
def find(job_id):
|
|
for j in JOBS:
|
|
if j["id"] == job_id:
|
|
return j
|
|
return None
|
|
|
|
|
|
def jlog(job, msg):
|
|
line = time.strftime("%H:%M:%S ") + msg
|
|
with LOCK:
|
|
job["log"].append(line)
|
|
del job["log"][:-300]
|
|
try:
|
|
print("[%s] %s" % (job["id"], msg))
|
|
except Exception: # consola de Windows sin UTF-8
|
|
print(("[%s] %s" % (job["id"], msg)).encode("ascii", "replace").decode())
|
|
|
|
|
|
def setj(job, **kw):
|
|
with LOCK:
|
|
job.update(kw)
|
|
|
|
|
|
def cfg_snapshot():
|
|
with LOCK:
|
|
return dict(SETTINGS)
|
|
|
|
|
|
def human(n):
|
|
n = float(n)
|
|
for u in ("B", "KB", "MB", "GB", "TB"):
|
|
if n < 1024 or u == "TB":
|
|
return "%.1f %s" % (n, u) if u != "B" else "%d B" % n
|
|
n /= 1024
|
|
|
|
|
|
class Meter:
|
|
"""Velocidad media de los ultimos segundos."""
|
|
|
|
def __init__(self):
|
|
self.lock = threading.Lock()
|
|
self.samples = []
|
|
self.total = 0
|
|
|
|
def __call__(self, n):
|
|
with self.lock:
|
|
self.total += n
|
|
now = time.time()
|
|
self.samples.append((now, self.total))
|
|
while self.samples and now - self.samples[0][0] > 6:
|
|
self.samples.pop(0)
|
|
|
|
def rate(self):
|
|
"""Media de los ultimos segundos. Si hay pocas muestras muy juntas (arranque,
|
|
reanudar), se divide al menos entre 2 s para no inventar velocidades absurdas."""
|
|
with self.lock:
|
|
now = time.time()
|
|
while self.samples and now - self.samples[0][0] > 6:
|
|
self.samples.pop(0)
|
|
if len(self.samples) < 2:
|
|
return 0.0
|
|
(t0, b0), (t1, b1) = self.samples[0], self.samples[-1]
|
|
if now - t1 > 3: # lleva rato sin llegar nada
|
|
return 0.0
|
|
return (b1 - b0) / max(2.0, t1 - t0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fase 1: comprobar enlaces y agrupar por conjunto de archivos
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def probe_job(job, cancel):
|
|
setj(job, status="comprobando", phase="Comprobando enlaces")
|
|
srcs = []
|
|
for line in job["lines"]:
|
|
if cancel.is_set():
|
|
raise E.Cancelled()
|
|
s = E.make_source(line)
|
|
jlog(job, "Comprobando: %s" % line[:120])
|
|
s.probe()
|
|
jlog(job, " %s (%s)%s" % (s.name, human(s.size) if s.size >= 0 else "tamano desconocido",
|
|
"" if s.ranges else " sin descarga en paralelo"))
|
|
srcs.append(s)
|
|
return srcs
|
|
|
|
|
|
def split_into_sets(srcs):
|
|
groups = {}
|
|
order = []
|
|
for s in srcs:
|
|
k = E.set_key(s.name)
|
|
if k not in groups:
|
|
groups[k] = []
|
|
order.append(k)
|
|
groups[k].append(s)
|
|
return [sorted(groups[k], key=lambda s: E.volume_order(s.name)) for k in order]
|
|
|
|
|
|
def describe(srcs):
|
|
return [{"input": s.original, "name": s.name, "size": s.size, "kind": s.kind} for s in srcs]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fase 2: enviar a la PS5
|
|
# ---------------------------------------------------------------------------
|
|
|
|
GiB = 1024 ** 3
|
|
RUNAWAY_SLACK = int(os.environ.get("PS5D_RUNAWAY_SLACK", 2 * 1024 ** 3))
|
|
|
|
|
|
def free_space(bf):
|
|
"""Bytes libres en /data segun BFpilot, o None si no responde."""
|
|
try:
|
|
st = bf._req("GET", "/api/fs/stat", {"path": "/data"})
|
|
v = st.get("availableBytes")
|
|
return int(v) if v is not None else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def margin_bytes():
|
|
with LOCK:
|
|
return max(0, int(SETTINGS.get("space_margin_gb", 25) or 0)) * GiB
|
|
|
|
|
|
def start_space_watch(job, bf, cancel, what):
|
|
"""Vigila el espacio libre mientras se envia o descomprime. Si baja del margen,
|
|
lo para todo antes de que la PS5 se quede sin sitio. Devuelve un Event para pararlo."""
|
|
done = threading.Event()
|
|
margin = margin_bytes()
|
|
if margin <= 0:
|
|
return done
|
|
|
|
def loop():
|
|
low_logged = False
|
|
while not done.wait(15):
|
|
free = free_space(bf)
|
|
if free is None:
|
|
continue
|
|
if free < margin:
|
|
with LOCK:
|
|
job["_stop"] = ("Parado para no llenar la PS5 (%s): quedan %s libres y el margen "
|
|
"es %s. Libera espacio y pulsa Reintentar; sigue donde iba."
|
|
% (what, human(free), human(margin)))
|
|
jlog(job, "ESPACIO: quedan %s libres (< %s). Paro %s." % (human(free), human(margin), what))
|
|
cancel.set()
|
|
return
|
|
if free < margin * 2 and not low_logged:
|
|
jlog(job, "Aviso: queda poco espacio en la PS5 (%s libres)" % human(free))
|
|
low_logged = True
|
|
|
|
threading.Thread(target=loop, daemon=True).start()
|
|
return done
|
|
|
|
|
|
def adopt_previous(job, bf, inc, names):
|
|
"""Mueve a 'inc' las partes que otros trabajos ya mandaron (completas o a medias)."""
|
|
root = WORK_ROOT + "/incoming"
|
|
try:
|
|
dirs = [e["name"] for e in bf.listdir(root) if e.get("dir")]
|
|
except Exception:
|
|
return
|
|
try:
|
|
bf.mkdir(inc)
|
|
except Exception:
|
|
pass
|
|
want = set(names) | {n + ".ps5part" for n in names}
|
|
for d in dirs:
|
|
path = "%s/%s" % (root, d)
|
|
if path == inc:
|
|
continue
|
|
try:
|
|
entries = bf.listdir(path)
|
|
except Exception:
|
|
continue
|
|
for e in entries:
|
|
if e["name"] in want and not bf.exists("%s/%s" % (inc, e["name"])):
|
|
try:
|
|
bf.rename("%s/%s" % (path, e["name"]), "%s/%s" % (inc, e["name"]))
|
|
jlog(job, "Aprovecho lo ya enviado: %s" % e["name"])
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def send_parts_parallel(job, cfg, srcs, inc, cancel, meter, set_sent):
|
|
"""Envia las partes de un comprimido, varias a la vez (cada una por su propia
|
|
conexion FTP). En Wi-Fi, dos conexiones suman mas que una sola."""
|
|
n = max(1, min(int(cfg.get("parallel_files", 2)), 4, len(srcs)))
|
|
if n > 1:
|
|
cfg = dict(cfg)
|
|
cfg["buffer_mb"] = max(64, int(cfg.get("buffer_mb", 256)) // n)
|
|
stop = threading.Event() # se activa si cancelas o si una parte falla
|
|
done = {} # parte -> bytes enviados
|
|
errors = []
|
|
lock = threading.Lock()
|
|
pending = list(srcs)
|
|
|
|
def relay_cancel():
|
|
while not stop.is_set():
|
|
if cancel.wait(0.5):
|
|
stop.set()
|
|
|
|
def progress(name, v):
|
|
with lock:
|
|
done[name] = v
|
|
total = sum(done.values())
|
|
set_sent(total)
|
|
|
|
def phase():
|
|
with lock:
|
|
active = [s.name for s in srcs if 0 < done.get(s.name, 0) < max(1, s.size)]
|
|
if active:
|
|
setj(job, phase="Enviando " + " + ".join(active))
|
|
|
|
def runner():
|
|
while not stop.is_set():
|
|
with lock:
|
|
if not pending:
|
|
return
|
|
s = pending.pop(0)
|
|
done.setdefault(s.name, 0)
|
|
try:
|
|
E.upload(cfg, "%s/%s" % (inc, s.name), s.size,
|
|
lambda off, s=s: s.stream(off, cfg, stop, meter),
|
|
lambda v, s=s: (progress(s.name, v), phase()),
|
|
stop, lambda msg: jlog(job, msg))
|
|
progress(s.name, max(0, s.size))
|
|
except Exception as e:
|
|
with lock:
|
|
errors.append(e)
|
|
stop.set()
|
|
return
|
|
|
|
threading.Thread(target=relay_cancel, daemon=True).start()
|
|
if n > 1:
|
|
jlog(job, "Enviando %d partes a la vez" % n)
|
|
threads = [threading.Thread(target=runner, daemon=True) for _ in range(n)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
stop.set()
|
|
if cancel.is_set():
|
|
raise E.Cancelled()
|
|
real = [e for e in errors if not isinstance(e, E.Cancelled)]
|
|
if real:
|
|
raise real[0]
|
|
if errors:
|
|
raise errors[0]
|
|
|
|
|
|
def live_limit_bps():
|
|
with LOCK:
|
|
return float(SETTINGS.get("limit_mbps", 0) or 0) * 1024 * 1024
|
|
|
|
|
|
def transfer_job(job, srcs, cancel):
|
|
cfg = cfg_snapshot()
|
|
cfg["_log"] = lambda m: jlog(job, m)
|
|
cfg["_limit_bps"] = live_limit_bps
|
|
if job.get("raw"):
|
|
return raw_job(job, srcs, cancel)
|
|
if srcs[0].kind == "folder":
|
|
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" 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
|
|
and all(E.SPLIT_RAW_RE.match(s.name) for s in srcs))
|
|
known = all(s.size >= 0 for s in srcs)
|
|
total = sum(s.size for s in srcs) if known else -1
|
|
# Carpeta por conjunto de archivos (no por trabajo): si mandas luego las partes que faltaban
|
|
# en otro trabajo, aprovecha lo que ya estaba en la consola.
|
|
inc = "%s/incoming/%s" % (WORK_ROOT, E.safe_name(E.set_key(srcs[0].name)) or job["id"])
|
|
meter = Meter() # descarga (lo que entra al PC)
|
|
up = Meter() # envio a la PS5 (lo que importa)
|
|
stop_speed = threading.Event()
|
|
last_sent = {"v": None}
|
|
sent_lock = threading.Lock()
|
|
|
|
def set_sent(v):
|
|
# Varias partes avisan a la vez y los avisos pueden llegar desordenados:
|
|
# solo cuenta lo que supera el maximo ya visto (nunca dos veces lo mismo).
|
|
with sent_lock:
|
|
prev = last_sent["v"]
|
|
if prev is not None and v <= prev:
|
|
return
|
|
if prev is not None and v - prev < 64 * 1024 * 1024:
|
|
up(v - prev)
|
|
last_sent["v"] = v
|
|
setj(job, sent=v)
|
|
|
|
def speed_loop():
|
|
while not stop_speed.wait(1):
|
|
setj(job, speed=up.rate(), dl_speed=meter.rate())
|
|
|
|
threading.Thread(target=speed_loop, daemon=True).start()
|
|
setj(job, status="enviando", total=total, sent=0)
|
|
bf = E.BFpilot(cfg)
|
|
|
|
adopt_previous(job, bf, inc, [s.name for s in srcs])
|
|
warn_duplicates(job, bf, E.title_id(srcs[0].name),
|
|
when=" (aviso antes de enviar: puedes pausarlo si no lo quieres)")
|
|
|
|
# Comprobacion de espacio: lo que falta por enviar + margen. La descompresion la
|
|
# vuelve a comprobar BFpilot antes de empezar (sabe lo que ocupa de verdad).
|
|
free = free_space(bf)
|
|
if free is not None and total > 0:
|
|
present = 0
|
|
try:
|
|
want = {s.name: s.size for s in srcs}
|
|
for e in bf.listdir(inc):
|
|
n = e.get("name", "")
|
|
base = n[:-8] if n.endswith(".ps5part") else n
|
|
if base in want:
|
|
present += min(int(e.get("size") or 0), max(0, want[base]))
|
|
except Exception:
|
|
pass
|
|
remaining = max(0, total - present)
|
|
margin = margin_bytes()
|
|
if remaining + margin > free:
|
|
stop_speed.set()
|
|
raise E.PipeError("No hay sitio en la PS5: faltan por enviar %s, hay %s libres y dejo %s "
|
|
"de margen (Ajustes). Libera espacio y pulsa Reintentar."
|
|
% (human(remaining), human(free), human(margin)))
|
|
if archives:
|
|
est = int(total * 5)
|
|
if free - remaining - est < margin:
|
|
jlog(job, "Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR (~%s) y no se "
|
|
"si cabra. Si al descomprimir baja del margen, lo paro y los RAR se quedan "
|
|
"en la PS5." % human(est))
|
|
jlog(job, "Espacio libre en la PS5: %s" % human(free))
|
|
watch = start_space_watch(job, bf, cancel, "el envio")
|
|
done_before = 0
|
|
try:
|
|
if archives or len(srcs) > 1 and not raw_split:
|
|
# Volumenes de un archivo comprimido: cada uno a su sitio, separados
|
|
send_parts_parallel(job, cfg, srcs, inc, cancel, meter, set_sent)
|
|
return {"mode": "archive", "dir": inc, "first": E.first_volume(names)}
|
|
if raw_split:
|
|
# .001 .002 ... -> se unen en un solo archivo mientras se envian
|
|
base_name = E.SPLIT_RAW_RE.match(srcs[0].name).group("base")
|
|
if not known:
|
|
raise E.PipeError("Para unir partes hace falta saber el tamano de cada una")
|
|
remote = "%s/%s" % (inc, base_name)
|
|
setj(job, phase="Enviando y uniendo %d partes en %s" % (len(srcs), base_name))
|
|
jlog(job, "Uniendo %d partes en %s" % (len(srcs), base_name))
|
|
E.upload(cfg, remote, total,
|
|
lambda off: E.concat_stream(srcs, off, cfg, cancel, meter),
|
|
lambda n: set_sent(n), cancel, lambda msg: jlog(job, msg))
|
|
if E.is_archive(base_name):
|
|
return {"mode": "archive", "dir": inc, "first": base_name}
|
|
return {"mode": "file", "path": remote, "name": base_name}
|
|
s = srcs[0]
|
|
remote = "%s/%s" % (inc, s.name)
|
|
setj(job, phase="Enviando %s" % s.name)
|
|
E.upload(cfg, remote, s.size,
|
|
lambda off: s.stream(off, cfg, cancel, meter),
|
|
lambda n: set_sent(n), cancel, lambda msg: jlog(job, msg))
|
|
return {"mode": "file", "path": remote, "name": s.name}
|
|
finally:
|
|
stop_speed.set()
|
|
watch.set()
|
|
setj(job, speed=0.0, dl_speed=0.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Envio "tal cual" (desde el explorador de la consola): sin descomprimir ni colocar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def raw_job(job, srcs, cancel):
|
|
cfg = cfg_snapshot()
|
|
cfg["_limit_bps"] = live_limit_bps
|
|
dest = (job.get("dest") or cfg["dest"]).rstrip("/") or "/"
|
|
total = sum(max(0, s.size) for s in srcs)
|
|
up, meter = Meter(), Meter()
|
|
state = {"base": 0, "last": 0}
|
|
stop_speed = threading.Event()
|
|
|
|
def speed_loop():
|
|
while not stop_speed.wait(1):
|
|
setj(job, speed=up.rate(), dl_speed=meter.rate())
|
|
threading.Thread(target=speed_loop, daemon=True).start()
|
|
|
|
def prog(v):
|
|
cur = state["base"] + v
|
|
if cur > state["last"]:
|
|
up(cur - state["last"])
|
|
state["last"] = cur
|
|
setj(job, sent=cur)
|
|
setj(job, status="enviando", total=total if total > 0 else -1, sent=0)
|
|
placed = []
|
|
try:
|
|
for s in srcs:
|
|
target = dest + "/" + s.name
|
|
if s.kind == "folder":
|
|
setj(job, phase="Enviando carpeta: %s" % s.name)
|
|
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),
|
|
prog, cancel, lambda m: jlog(job, m))
|
|
state["base"] += max(0, s.size)
|
|
placed.append(target)
|
|
finally:
|
|
stop_speed.set()
|
|
setj(job, speed=0.0, dl_speed=0.0)
|
|
return {"mode": "done", "placed": placed, "game": False}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Historial (pestana Actividad)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
HISTORY_FILE = os.path.join(DATA_DIR, "history.json")
|
|
HISTORY_LOCK = threading.Lock()
|
|
|
|
|
|
def record_history(job):
|
|
try:
|
|
with HISTORY_LOCK:
|
|
try:
|
|
with open(HISTORY_FILE, encoding="utf-8") as f:
|
|
hist = json.load(f)
|
|
except Exception:
|
|
hist = []
|
|
hist.append({
|
|
"t": time.time(), "id": job["id"], "title": job.get("title") or "",
|
|
"status": job.get("status"), "size": job.get("total", -1),
|
|
"result": job.get("result") or [], "error": job.get("error") or "",
|
|
"secs": int(time.time() - float(job.get("started") or job.get("created") or time.time())),
|
|
"where": "raw" if job.get("raw") else ("pc" if (job.get("info") or {}).get("mode") == "pcx" else "ps5"),
|
|
"dest": job.get("dest") or "",
|
|
})
|
|
_save_json(HISTORY_FILE, hist[-500:])
|
|
except Exception:
|
|
traceback.print_exc()
|
|
|
|
|
|
def read_history():
|
|
try:
|
|
with open(HISTORY_FILE, encoding="utf-8") as f:
|
|
return list(reversed(json.load(f)))
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Explorador de archivos de la consola
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FMOP = {"busy": False, "op": "", "current": "", "items_done": 0, "items_total": 0,
|
|
"bytes_done": 0, "bytes_total": 0, "error": "", "message": "", "t0": 0.0, "local": ""}
|
|
FM_CANCEL = threading.Event()
|
|
|
|
|
|
def fm_clean(path):
|
|
path = "/" + "/".join(x for x in str(path or "/").replace("\\", "/").split("/") if x and x != ".")
|
|
if ".." in path.split("/"):
|
|
raise E.PipeError("Ruta no valida")
|
|
return path
|
|
|
|
|
|
def fm_list(path):
|
|
cfg = cfg_snapshot()
|
|
path = fm_clean(path)
|
|
bf = E.BFpilot(cfg)
|
|
out = {"path": path, "entries": [], "free": None, "total": None, "via": "bfpilot"}
|
|
if bf.alive():
|
|
ents = bf.listdir(path)
|
|
out["entries"] = [{"name": e.get("name"), "dir": bool(e.get("dir")), "size": e.get("size"),
|
|
"mtime": e.get("mtime"), "mode": e.get("mode")} for e in ents]
|
|
try:
|
|
st = bf._req("GET", "/api/fs/stat", {"path": path})
|
|
out["free"] = st.get("availableBytes")
|
|
out["total"] = st.get("usableTotalBytes") or st.get("totalBytes")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
out["via"] = "ftp"
|
|
f = E.Ftp(cfg).connect()
|
|
try:
|
|
for name, facts in f.mlsd(path, facts=["type", "size", "modify", "unix.mode"]):
|
|
if facts.get("type") not in ("file", "dir"):
|
|
continue
|
|
mt = facts.get("modify")
|
|
try:
|
|
mt = int(time.mktime(time.strptime(mt[:14], "%Y%m%d%H%M%S"))) if mt else None
|
|
except Exception:
|
|
mt = None
|
|
mode = facts.get("unix.mode")
|
|
out["entries"].append({"name": name, "dir": facts.get("type") == "dir",
|
|
"size": int(facts.get("size") or 0), "mtime": mt,
|
|
"mode": int(mode, 8) if mode else None})
|
|
finally:
|
|
try:
|
|
f.quit()
|
|
except Exception:
|
|
pass
|
|
out["entries"].sort(key=lambda e: (not e["dir"], (e["name"] or "").lower()))
|
|
return out
|
|
|
|
|
|
def fm_bf():
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
if not bf.alive():
|
|
raise E.PipeError("BFpilot no responde: cargalo para copiar, mover, borrar o calcular tamanos")
|
|
return bf
|
|
|
|
|
|
def _fm_start(op, items_total, fn):
|
|
with LOCK:
|
|
if FMOP["busy"]:
|
|
raise E.PipeError("Ya hay una operacion en marcha en el explorador")
|
|
FMOP.update(busy=True, op=op, current="", items_done=0, items_total=items_total, bytes_done=0,
|
|
bytes_total=0, error="", message="", t0=time.time(), local="")
|
|
FM_CANCEL.clear()
|
|
|
|
def run():
|
|
try:
|
|
fn()
|
|
with LOCK:
|
|
if not FMOP["message"]:
|
|
FMOP["message"] = "Hecho"
|
|
except E.Cancelled:
|
|
with LOCK:
|
|
FMOP["error"] = "Cancelado"
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
with LOCK:
|
|
FMOP["error"] = str(e)
|
|
finally:
|
|
with LOCK:
|
|
FMOP["busy"] = False
|
|
threading.Thread(target=run, daemon=True).start()
|
|
|
|
|
|
def fm_paste(op, srcs, dst, overwrite=False):
|
|
bf = fm_bf()
|
|
dst = fm_clean(dst)
|
|
srcs = [fm_clean(x) for x in srcs]
|
|
for s in srcs:
|
|
if dst == s or dst.startswith(s + "/"):
|
|
raise E.PipeError("No se puede %s una carpeta dentro de si misma" % ("mover" if op == "move" else "copiar"))
|
|
|
|
def work():
|
|
for i, s in enumerate(srcs):
|
|
if FM_CANCEL.is_set():
|
|
raise E.Cancelled()
|
|
with LOCK:
|
|
FMOP.update(current=s, items_done=i, bytes_done=0, bytes_total=0)
|
|
if op == "move" and posixpath.dirname(s) == dst:
|
|
continue
|
|
bf._req("POST", "/api/fs/" + op, form={"src": s, "dst": dst, "overwrite": "1" if overwrite else "0"})
|
|
while True:
|
|
time.sleep(0.7)
|
|
if FM_CANCEL.is_set():
|
|
try:
|
|
bf._req("GET", "/api/fs/job/cancel")
|
|
except Exception:
|
|
pass
|
|
j = bf._req("GET", "/api/fs/job/status")
|
|
with LOCK:
|
|
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
|
current=j.get("current") or s)
|
|
if not j.get("busy"):
|
|
if j.get("error"):
|
|
raise E.PipeError(j["error"])
|
|
break
|
|
if FM_CANCEL.is_set():
|
|
raise E.Cancelled()
|
|
with LOCK:
|
|
FMOP["items_done"] = len(srcs)
|
|
_fm_start(op, len(srcs), work)
|
|
|
|
|
|
def fm_delete(paths):
|
|
bf = fm_bf()
|
|
paths = [fm_clean(x) for x in paths]
|
|
for p in paths:
|
|
if p.count("/") < 2:
|
|
raise E.PipeError("Por seguridad no borro carpetas de primer nivel (%s)" % p)
|
|
|
|
def work():
|
|
for i, p in enumerate(paths):
|
|
if FM_CANCEL.is_set():
|
|
raise E.Cancelled()
|
|
with LOCK:
|
|
FMOP.update(current=p, items_done=i)
|
|
bf.delete(p, FM_CANCEL)
|
|
with LOCK:
|
|
FMOP["items_done"] = len(paths)
|
|
_fm_start("delete", len(paths), work)
|
|
|
|
|
|
def fm_download(paths):
|
|
cfg = cfg_snapshot()
|
|
paths = [fm_clean(x) for x in paths]
|
|
root_local = os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo")
|
|
|
|
def walk(rpath):
|
|
"""[(ruta remota, ruta relativa, tamano)] de un archivo o carpeta."""
|
|
out = []
|
|
base = posixpath.basename(rpath)
|
|
listing = None
|
|
try:
|
|
listing = fm_list(rpath)["entries"]
|
|
except Exception:
|
|
listing = None
|
|
if listing is None:
|
|
return [(rpath, base, None)]
|
|
stack = [(rpath, base, listing)]
|
|
while stack:
|
|
cur, rel, ents = stack.pop()
|
|
for e in ents:
|
|
rp, rr = cur + "/" + e["name"], rel + "/" + e["name"]
|
|
if e["dir"]:
|
|
stack.append((rp, rr, fm_list(rp)["entries"]))
|
|
else:
|
|
out.append((rp, rr, e.get("size")))
|
|
return out
|
|
|
|
def work():
|
|
files = []
|
|
for p in paths:
|
|
parent = fm_list(posixpath.dirname(p) or "/")["entries"]
|
|
ent = next((e for e in parent if e["name"] == posixpath.basename(p)), None)
|
|
if ent and not ent["dir"]:
|
|
files.append((p, posixpath.basename(p), ent.get("size")))
|
|
else:
|
|
files.extend(walk(p))
|
|
total = sum(sz or 0 for _, _, sz in files)
|
|
with LOCK:
|
|
FMOP.update(items_total=len(files), bytes_total=total, local=root_local)
|
|
done = 0
|
|
for i, (rp, rel, sz) in enumerate(files):
|
|
if FM_CANCEL.is_set():
|
|
raise E.Cancelled()
|
|
with LOCK:
|
|
FMOP.update(current=rp, items_done=i)
|
|
local = os.path.join(root_local, *rel.split("/"))
|
|
if sz is not None and os.path.isfile(local) and os.path.getsize(local) == sz:
|
|
done += sz
|
|
continue
|
|
base = done
|
|
E.ftp_download(cfg, rp, local, FM_CANCEL, lambda n: FMOP.update(bytes_done=base + n), sz)
|
|
done = base + (sz or 0)
|
|
with LOCK:
|
|
FMOP.update(items_done=len(files), bytes_done=done,
|
|
message="Guardado en %s" % root_local)
|
|
_fm_start("download", len(paths), work)
|
|
|
|
|
|
def fm_chmod(paths):
|
|
bf = fm_bf()
|
|
paths = [fm_clean(x) for x in paths]
|
|
|
|
def work():
|
|
changed = 0
|
|
for i, p in enumerate(paths):
|
|
with LOCK:
|
|
FMOP.update(current=p, items_done=i)
|
|
c, _, _ = bf.fix_perms(p, FM_CANCEL)
|
|
changed += c
|
|
with LOCK:
|
|
FMOP.update(items_done=len(paths), message="Permisos 777 puestos en %d elementos" % changed)
|
|
_fm_start("chmod", len(paths), work)
|
|
|
|
|
|
PICK_SCRIPT = r'''
|
|
import json, sys, os
|
|
import tkinter as tk
|
|
from tkinter import filedialog
|
|
r = tk.Tk(); r.withdraw()
|
|
try:
|
|
r.attributes("-topmost", True)
|
|
except Exception:
|
|
pass
|
|
kind, title = sys.argv[1], sys.argv[2]
|
|
if kind == "folder":
|
|
p = filedialog.askdirectory(title=title, mustexist=True)
|
|
res = [p] if p else []
|
|
else:
|
|
res = list(filedialog.askopenfilenames(title=title))
|
|
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":
|
|
return {"size": fm_bf().du(fm_clean(b.get("path")))}
|
|
if p == "/api/fm/mkdir":
|
|
path = fm_clean(b.get("path"))
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
if bf.alive():
|
|
bf.mkdir(path)
|
|
else:
|
|
E.Ftp(cfg_snapshot()).call(lambda f: f.mkd(path))
|
|
return {}
|
|
if p == "/api/fm/rename":
|
|
src, dst = fm_clean(b.get("src")), fm_clean(b.get("dst"))
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
if bf.alive():
|
|
if bf.exists(dst):
|
|
raise E.PipeError("Ya existe %s" % dst)
|
|
bf.rename(src, dst)
|
|
else:
|
|
E.Ftp(cfg_snapshot()).call(lambda f: f.rename(src, dst))
|
|
return {}
|
|
if p == "/api/fm/paste":
|
|
op = b.get("op")
|
|
if op not in ("copy", "move"):
|
|
raise E.PipeError("operacion no valida")
|
|
fm_paste(op, b.get("srcs") or [], b.get("dst"), bool(b.get("overwrite")))
|
|
return {}
|
|
if p == "/api/fm/delete":
|
|
fm_delete(b.get("paths") or [])
|
|
return {}
|
|
if p == "/api/fm/download":
|
|
fm_download(b.get("paths") or [])
|
|
return {}
|
|
if p == "/api/fm/chmod":
|
|
fm_chmod(b.get("paths") or [])
|
|
return {}
|
|
if p == "/api/fm/op":
|
|
with LOCK:
|
|
return {"op": dict(FMOP)}
|
|
if p == "/api/fm/cancel":
|
|
FM_CANCEL.set()
|
|
return {}
|
|
if p == "/api/fm/open_local":
|
|
path = FMOP.get("local") or os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo")
|
|
if os.name == "nt" and os.path.isdir(path):
|
|
os.startfile(path)
|
|
return {"path": path}
|
|
if p == "/api/pick":
|
|
return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")}
|
|
if p == "/api/history":
|
|
return {"items": read_history()}
|
|
if p == "/api/history/clear":
|
|
_save_json(HISTORY_FILE, [])
|
|
return {}
|
|
raise E.PipeError("no existe")
|
|
|
|
|
|
def pick_paths(kind, title):
|
|
"""Abre el dialogo de Windows para elegir archivos o una carpeta del PC."""
|
|
import subprocess
|
|
exe = sys.executable
|
|
if os.name == "nt" and exe.lower().endswith("pythonw.exe"):
|
|
alt = exe[:-len("pythonw.exe")] + "python.exe"
|
|
exe = alt if os.path.isfile(alt) else exe
|
|
kw = {"creationflags": 0x08000000} if os.name == "nt" else {}
|
|
p = subprocess.run([exe, "-c", PICK_SCRIPT, kind, title or ""], stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=3600, **kw)
|
|
if p.returncode != 0:
|
|
raise E.PipeError("No pude abrir el selector de archivos: %s"
|
|
% (p.stderr.decode("utf-8", "replace").strip().splitlines() or ["?"])[-1])
|
|
return json.loads(p.stdout.decode("utf-8", "replace").strip() or "[]")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Carpetas del PC: se mandan tal cual, saltando lo que ya este en la consola
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def folder_job(job, src, cancel):
|
|
cfg = cfg_snapshot()
|
|
cfg["_limit_bps"] = live_limit_bps
|
|
dest = (job.get("dest") or cfg["dest"]).rstrip("/")
|
|
tid = None
|
|
pj = os.path.join(src.path, "sce_sys", "param.json")
|
|
if os.path.isfile(pj):
|
|
try:
|
|
with open(pj, encoding="utf-8", errors="replace") as fh:
|
|
tid = E.title_id(fh.read())
|
|
except Exception:
|
|
pass
|
|
tid = tid or E.title_id(src.name)
|
|
name = (tid + "-app") if tid else src.name
|
|
target = dest + "/" + name
|
|
total = src.size
|
|
jlog(job, "Carpeta con %d archivos (%s) -> %s" % (len(src.files), human(total), target))
|
|
bf = E.BFpilot(cfg)
|
|
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())))
|
|
if tid:
|
|
warn_duplicates(job, bf, tid, exclude=target)
|
|
up = Meter()
|
|
last = {"v": 0, "t": 0.0}
|
|
stop_speed = threading.Event()
|
|
|
|
def speed_loop():
|
|
while not stop_speed.wait(1):
|
|
setj(job, speed=up.rate(), dl_speed=0.0)
|
|
threading.Thread(target=speed_loop, daemon=True).start()
|
|
|
|
def progress(v, rel):
|
|
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="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,
|
|
on_plan=plan)
|
|
finally:
|
|
stop_speed.set()
|
|
watch.set()
|
|
setj(job, speed=0.0)
|
|
setj(job, sent=total)
|
|
jlog(job, "Carpeta enviada: %d archivos nuevos, %d ya estaban iguales, %d continuados"
|
|
% (sent, skipped, resumed))
|
|
if bf.alive():
|
|
fix_game_perms(job, bf, target, cancel)
|
|
return {"mode": "done", "placed": [target], "game": bool(tid)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alternativa: descomprimir en el PC con 7-Zip y mandar el contenido ya suelto
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PCX_DIR = os.path.join(DATA_DIR, "pc_extract")
|
|
|
|
|
|
def job_where(job):
|
|
with LOCK:
|
|
w = job.get("where") or SETTINGS.get("extract_where") or "ps5"
|
|
return "pc" if w == "pc" else "ps5"
|
|
|
|
|
|
def pc_temp_root(cfg):
|
|
d = (cfg.get("pc_temp_dir") or "").strip().strip('"')
|
|
return d or os.path.join(os.path.expanduser("~"), "Downloads", "PS5 Directo (temporal)")
|
|
|
|
|
|
def _pcx_state(job):
|
|
try:
|
|
with open(os.path.join(PCX_DIR, job["id"] + ".json"), encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _pcx_save(job, st):
|
|
try:
|
|
os.makedirs(PCX_DIR, exist_ok=True)
|
|
tmp = os.path.join(PCX_DIR, job["id"] + ".json.tmp")
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump(st, f)
|
|
os.replace(tmp, os.path.join(PCX_DIR, job["id"] + ".json"))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _pcx_forget(job):
|
|
try:
|
|
os.remove(os.path.join(PCX_DIR, job["id"] + ".json"))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def fetch_to_pc(job, srcs, cfg, cancel, meter, folder):
|
|
"""Baja al PC las partes que son enlaces (las de tu PC se usan tal cual).
|
|
Devuelve {nombre: ruta local} y la lista de archivos que hemos creado nosotros."""
|
|
import shutil
|
|
local, created = {}, []
|
|
urls = [s for s in srcs if s.kind != "file"]
|
|
for s in srcs:
|
|
if s.kind == "file":
|
|
local[s.name] = s.path
|
|
if not urls:
|
|
return local, created
|
|
if any(s.size < 0 for s in urls):
|
|
raise E.PipeError("Para bajar al PC hace falta saber el tamano de cada parte")
|
|
os.makedirs(folder, exist_ok=True)
|
|
|
|
def have(s):
|
|
final = os.path.join(folder, s.name)
|
|
if os.path.isfile(final) and os.path.getsize(final) == s.size:
|
|
return s.size
|
|
part = final + ".part"
|
|
return min(os.path.getsize(part), s.size) if os.path.isfile(part) else 0
|
|
|
|
total = sum(s.size for s in urls)
|
|
missing = sum(s.size - have(s) for s in urls)
|
|
free = shutil.disk_usage(folder).free
|
|
if missing + 2 * GiB > free:
|
|
raise E.PipeError("No cabe en el PC: hay que bajar %s a %s y solo hay %s libres. Libera espacio, "
|
|
"cambia la carpeta temporal en Ajustes o descomprime en la PS5."
|
|
% (human(missing), folder, human(free)))
|
|
jlog(job, "Bajando %d partes al PC (%s) en %s" % (len(urls), human(total), folder))
|
|
done_before = 0
|
|
for k, s in enumerate(urls, 1):
|
|
final = os.path.join(folder, s.name)
|
|
created.append(final)
|
|
local[s.name] = final
|
|
if os.path.isfile(final) and os.path.getsize(final) == s.size:
|
|
done_before += s.size
|
|
setj(job, sent=done_before)
|
|
continue
|
|
part = final + ".part"
|
|
off = os.path.getsize(part) if os.path.isfile(part) else 0
|
|
if off > s.size:
|
|
off = 0
|
|
if off:
|
|
jlog(job, "%s: continuando desde %s" % (s.name, human(off)))
|
|
setj(job, phase="Bajando al PC: parte %d de %d (%s)" % (k, len(urls), s.name))
|
|
got = off
|
|
with open(part, "r+b" if off else "wb") as f:
|
|
f.seek(off)
|
|
f.truncate()
|
|
for data in s.stream(off, cfg, cancel, meter):
|
|
f.write(data)
|
|
got += len(data)
|
|
setj(job, sent=done_before + got)
|
|
if got != s.size:
|
|
raise E.PipeError("La descarga de %s se quedo corta (%s de %s). Pulsa Reintentar."
|
|
% (s.name, human(got), human(s.size)))
|
|
os.replace(part, final)
|
|
done_before += s.size
|
|
jlog(job, "En el PC: %s" % s.name)
|
|
return local, created
|
|
|
|
|
|
def pc_extract_job(job, srcs, cancel):
|
|
cfg = cfg_snapshot()
|
|
cfg["_limit_bps"] = live_limit_bps
|
|
log = lambda m: jlog(job, m) # noqa: E731
|
|
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'.")
|
|
folder = os.path.join(pc_temp_root(cfg), E.safe_name(E.set_key(srcs[0].name)))
|
|
dl, up = Meter(), Meter()
|
|
phase = {"dl": True}
|
|
stop_speed = threading.Event()
|
|
|
|
def speed_loop():
|
|
while not stop_speed.wait(1):
|
|
if phase["dl"]:
|
|
setj(job, speed=dl.rate(), dl_speed=0.0)
|
|
else:
|
|
setj(job, speed=up.rate(), dl_speed=0.0)
|
|
threading.Thread(target=speed_loop, daemon=True).start()
|
|
bf = E.BFpilot(cfg)
|
|
watch = None
|
|
try:
|
|
# 1) Las partes tienen que estar en el PC (7-Zip necesita poder saltar de una a otra)
|
|
if any(s.kind != "file" for s in srcs):
|
|
setj(job, status="enviando", total=sum(max(0, s.size) for s in srcs), sent=0,
|
|
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="",
|
|
pc_step="enviar")
|
|
cands = password_candidates(job, cfg)
|
|
try:
|
|
entries, props = E.list_7z(exe, first)
|
|
list_pw = None
|
|
except E.PipeError as e:
|
|
if not E._pw_error(str(e)):
|
|
raise
|
|
entries = None
|
|
for pw in cands:
|
|
try:
|
|
entries, props = E.list_7z(exe, first, pw)
|
|
list_pw = pw
|
|
break
|
|
except E.PipeError:
|
|
continue
|
|
if entries is None:
|
|
raise E.PipeError("Ninguna contrasena abre el comprimido. Escribela en el trabajo o en "
|
|
"Ajustes y pulsa Reintentar (no se vuelve a descargar).")
|
|
files = [e for e in entries if not e["dir"]]
|
|
total = sum(e["size"] for e in files)
|
|
jlog(job, "Dentro hay %d archivos que ocupan %s descomprimidos%s"
|
|
% (len(files), human(total), " (solido)" if props.get("Solid") == "+" else ""))
|
|
if list_pw is not None:
|
|
pws = [list_pw]
|
|
elif E.needs_password(entries):
|
|
pws = cands
|
|
if not pws:
|
|
raise E.PipeError("El comprimido tiene contrasena. Escribela en el trabajo o en Ajustes "
|
|
"y pulsa Reintentar.")
|
|
else:
|
|
pws = [None]
|
|
|
|
# 3) Sitio en la PS5: ahora se sabe exacto lo que ocupara todo
|
|
staging = "%s/extract/%s" % (WORK_ROOT, job["id"])
|
|
sig = "%s|%d|%d" % (entries[0]["path"] if entries else "", len(entries), total)
|
|
st = _pcx_state(job)
|
|
skip = int(st.get("done") or 0) if st.get("sig") == sig else 0
|
|
if skip:
|
|
try:
|
|
if not bf.alive() or not bf.exists(staging):
|
|
skip = 0
|
|
except Exception:
|
|
skip = 0
|
|
already = sum(e["size"] for e in entries[:skip])
|
|
free = free_space(bf)
|
|
margin = margin_bytes()
|
|
if free is not None:
|
|
need = total - already
|
|
if need + margin > free:
|
|
raise E.PipeError("No cabe en la PS5: descomprimido ocupa %s, faltan por mandar %s, "
|
|
"hay %s libres y dejo %s de margen. Libera espacio y pulsa Reintentar."
|
|
% (human(total), human(need), human(free), human(margin)))
|
|
jlog(job, "Espacio libre en la PS5: %s (hacen falta %s)" % (human(free), human(need)))
|
|
watch = start_space_watch(job, bf, cancel, "el envio")
|
|
|
|
# 4) Descomprimir y mandar, archivo a archivo
|
|
phase["dl"] = False
|
|
setj(job, status="enviando", total=total, sent=already, pc_step="enviar",
|
|
phase="Descomprimiendo en el PC y enviando a la PS5")
|
|
ordinal = {}
|
|
for e in entries:
|
|
if not e["dir"]:
|
|
ordinal[e["path"]] = len(ordinal) + 1
|
|
if skip:
|
|
jlog(job, "Continuo donde iba: %s ya estaban en la PS5" % human(already))
|
|
last = {"v": already, "t": 0.0}
|
|
|
|
def progress(v, path):
|
|
if v > last["v"]:
|
|
up(v - last["v"])
|
|
last["v"] = v
|
|
now = time.time()
|
|
if now - last["t"] > 0.5:
|
|
last["t"] = now
|
|
name = path.rsplit("/", 1)[-1]
|
|
setj(job, sent=v, phase="Descomprimiendo en el PC y enviando: archivo %d de %d · %s"
|
|
% (ordinal.get(path, 0), len(ordinal), name[-60:]))
|
|
|
|
# Donde estan las aplicaciones dentro del comprimido (carpeta con sce_sys/param.json)
|
|
capture = {}
|
|
roots = []
|
|
for e in entries:
|
|
low = e["path"].lower()
|
|
if not e["dir"] and (low == "sce_sys/param.json" or low.endswith("/sce_sys/param.json")):
|
|
root = e["path"][:-len("sce_sys/param.json")].rstrip("/")
|
|
if not any(root == r or root.startswith(r + "/") for r in roots if r) and "" not in roots:
|
|
roots.append(root)
|
|
capture[e["path"]] = None
|
|
state = {"done": skip, "files": 0}
|
|
|
|
def on_file(n):
|
|
state["done"] = n
|
|
if not entries[n - 1]["dir"]:
|
|
state["files"] += 1
|
|
_pcx_save(job, {"sig": sig, "done": n})
|
|
|
|
for k, pw in enumerate(pws):
|
|
start = state["done"]
|
|
files_before = state["files"]
|
|
if pw:
|
|
jlog(job, "Probando contrasena %d de %d" % (k + 1, len(pws)))
|
|
try:
|
|
E.stream_extract_7z(exe, first, entries, pw, staging, cfg, cancel, log, progress,
|
|
on_file=on_file, skip=start, capture=capture)
|
|
if pw:
|
|
jlog(job, "Contrasena correcta")
|
|
break
|
|
except E.PipeError as e:
|
|
if cancel.is_set():
|
|
raise E.Cancelled()
|
|
bad_pw = state["files"] == files_before and len(pws) > 1 and (
|
|
"contrasena" in str(e) or "CRC" in str(e))
|
|
if bad_pw and k + 1 < len(pws):
|
|
continue
|
|
if bad_pw:
|
|
raise E.PipeError("Ninguna contrasena ha funcionado. Escribela en el trabajo o en "
|
|
"Ajustes y pulsa Reintentar (no se vuelve a descargar).")
|
|
raise
|
|
setj(job, sent=total)
|
|
jlog(job, "Enviado ya descomprimido: %d archivos, %s, todos comprobados con su CRC"
|
|
% (len(files), human(total)))
|
|
games = []
|
|
for root in roots:
|
|
pj = (root + "/" if root else "") + "sce_sys/param.json"
|
|
raw = capture.get(pj) or b""
|
|
tid = (E.title_id(raw.decode("utf-8", "replace")) or E.title_id(root)
|
|
or E.title_id(srcs[0].name))
|
|
games.append({"root": root, "tid": tid})
|
|
return {"mode": "pcx", "staging": staging, "hint": E.display_name(srcs[0].name), "games": games,
|
|
"pc_files": created, "pc_folder": folder if created else ""}
|
|
finally:
|
|
stop_speed.set()
|
|
if watch:
|
|
watch.set()
|
|
setj(job, speed=0.0, dl_speed=0.0, speed_label="", sent_label="")
|
|
|
|
|
|
def ftp_place(job, info, dest, cfg):
|
|
"""Coloca lo enviado en modo PC solo con FTP (sin BFpilot): renombrar es instantaneo."""
|
|
staging = info["staging"]
|
|
hint = info.get("hint") or job["id"]
|
|
ftp = E.Ftp(cfg)
|
|
f = ftp.connect()
|
|
|
|
def is_dir(path):
|
|
try:
|
|
f.cwd(path)
|
|
f.cwd("/")
|
|
return True
|
|
except Exception:
|
|
return False
|
|
try:
|
|
if not is_dir(staging):
|
|
_pcx_forget(job)
|
|
raise E.PipeError("No encuentro en la PS5 lo enviado (%s). Pulsa Reintentar para mandarlo "
|
|
"de nuevo." % staging)
|
|
games = info.get("games") or []
|
|
moves = []
|
|
if games:
|
|
for g in games:
|
|
src = staging + ("/" + g["root"] if g["root"] else "")
|
|
name = (g["tid"] + "-app") if g.get("tid") else E.safe_name(
|
|
g["root"].rsplit("/", 1)[-1] if g["root"] else hint)
|
|
moves.append((src, dest + "/" + name))
|
|
else:
|
|
moves.append((staging, dest + "/" + E.safe_name(hint)))
|
|
placed = []
|
|
for src, target in moves:
|
|
if is_dir(target):
|
|
raise E.PipeError("Ya existe %s. No lo piso: borralo o renombralo y pulsa Reintentar. "
|
|
"Lo enviado esta en %s" % (target, src))
|
|
E.Ftp.mkdirs(f, dest)
|
|
f.rename(src, target)
|
|
jlog(job, "Colocado: %s" % target)
|
|
placed.append(target)
|
|
job["_placed_game"] = bool(games)
|
|
# carpeta de trabajo vacia (si venia dentro de otra carpeta, quedan restos)
|
|
try:
|
|
f.rmd(staging)
|
|
except Exception:
|
|
if is_dir(staging):
|
|
jlog(job, "Quedan restos en %s (vacios). Puedes borrarlos con Limpieza." % staging)
|
|
return placed
|
|
finally:
|
|
try:
|
|
f.quit()
|
|
except Exception:
|
|
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")
|
|
if not bf.alive():
|
|
jlog(job, "BFpilot no esta cargado: lo coloco por FTP")
|
|
placed = ftp_place(job, info, dest, cfg)
|
|
else:
|
|
if not bf.exists(staging):
|
|
_pcx_forget(job)
|
|
raise E.PipeError("No encuentro en la PS5 lo enviado (%s). Pulsa Reintentar para mandarlo "
|
|
"de nuevo." % staging)
|
|
placed = place(job, bf, staging, dest, cancel, info.get("hint") or job["id"])
|
|
for target in placed:
|
|
fix_game_perms(job, bf, target, cancel)
|
|
if bf.exists(staging):
|
|
try:
|
|
bf.delete(staging, cancel)
|
|
except Exception as e:
|
|
jlog(job, "No pude borrar %s: %s" % (staging, e))
|
|
_pcx_forget(job)
|
|
pc_files = info.get("pc_files") or []
|
|
if pc_files and cfg.get("delete_archives", True):
|
|
for pth in pc_files:
|
|
try:
|
|
os.remove(pth)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
os.rmdir(info.get("pc_folder") or "")
|
|
except Exception:
|
|
pass
|
|
jlog(job, "Borradas del PC las partes descargadas")
|
|
elif pc_files:
|
|
jlog(job, "Las partes descargadas se quedan en %s" % info.get("pc_folder"))
|
|
return placed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fase 3: extraer en la PS5 y colocar el resultado
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def wait_bfpilot(job, bf, cancel):
|
|
first = True
|
|
while True:
|
|
v = bf.alive()
|
|
if v:
|
|
return v
|
|
if first:
|
|
jlog(job, "BFpilot no responde. Cargalo en la PS5 desde Payload Manager; sigo esperando.")
|
|
first = False
|
|
setj(job, phase="Esperando a BFpilot (cargalo desde Payload Manager)")
|
|
if cancel.wait(10):
|
|
raise E.Cancelled()
|
|
|
|
|
|
def find_payloads(bf, root, depth=4):
|
|
"""Busca aplicaciones (carpetas con sce_sys), imagenes y pkg dentro de lo extraido."""
|
|
found = []
|
|
|
|
def walk(path, d):
|
|
entries = bf.listdir(path)
|
|
names = {e["name"]: e for e in entries}
|
|
if "sce_sys" in names and names["sce_sys"].get("dir"):
|
|
sub = {e["name"] for e in bf.listdir(path + "/sce_sys")}
|
|
if "param.json" in sub or "param.sfo" in sub:
|
|
found.append(("game", path))
|
|
return
|
|
for e in entries:
|
|
full = path + "/" + e["name"]
|
|
low = e["name"].lower()
|
|
if e.get("dir"):
|
|
if d < depth and not low.startswith(".bfpilot-extracting"):
|
|
walk(full, d + 1)
|
|
elif low.endswith(E.IMAGE_EXTS):
|
|
found.append(("image", full))
|
|
elif low.endswith(".pkg"):
|
|
found.append(("pkg", full))
|
|
|
|
walk(root, 0)
|
|
return found
|
|
|
|
|
|
def game_bases(bf):
|
|
"""Carpetas que escanea ShadowMountPlus: interno + USB/externos montados."""
|
|
bases = ["/data/homebrew"]
|
|
pl = bf.places() or {}
|
|
for m in (pl.get("mounts") or []) + (pl.get("places") or []):
|
|
path = (m.get("path") or "").rstrip("/")
|
|
if re.match(r"^/mnt/(usb|ext)\d+$", path):
|
|
for b in (path + "/homebrew", path):
|
|
if b not in bases:
|
|
bases.append(b)
|
|
return bases
|
|
|
|
|
|
def fix_all_perms():
|
|
"""Repasa las aplicaciones en carpeta de /data/homebrew (y USB) y les pone permisos 777."""
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
if not bf.alive():
|
|
raise E.PipeError("Hace falta BFpilot cargado para revisar los permisos")
|
|
out = []
|
|
for base in game_bases(bf):
|
|
try:
|
|
ents = bf.listdir(base)
|
|
except Exception:
|
|
continue
|
|
for e in ents:
|
|
if not e.get("dir"):
|
|
continue
|
|
path = base + "/" + e["name"]
|
|
try:
|
|
changed, seen, notable = bf.fix_perms(path)
|
|
out.append({"path": path, "changed": changed, "seen": seen + 1,
|
|
"eboot": ["%o" % v for v in notable.values()][:1]})
|
|
except Exception as ex:
|
|
out.append({"path": path, "error": str(ex)})
|
|
return out
|
|
|
|
|
|
def find_duplicates(bf, tid, exclude=None):
|
|
"""Rutas que ya contienen ese Title ID (carpetas o imagenes)."""
|
|
if not tid:
|
|
return []
|
|
out = []
|
|
for base in game_bases(bf):
|
|
try:
|
|
entries = bf.listdir(base)
|
|
except Exception:
|
|
continue
|
|
for e in entries:
|
|
if tid.upper() in e["name"].upper():
|
|
p = "%s/%s" % (base, e["name"])
|
|
if p != exclude:
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def warn_duplicates(job, bf, tid, exclude=None, when=""):
|
|
try:
|
|
dups = find_duplicates(bf, tid, exclude)
|
|
except Exception:
|
|
return
|
|
if dups:
|
|
msg = ("Ojo: ya tienes %s en %s. Dos copias con el mismo Title ID pueden liar a "
|
|
"ShadowMountPlus (deja solo una)." % (tid, ", ".join(dups[:4])))
|
|
jlog(job, msg + when)
|
|
with LOCK:
|
|
job["warning"] = msg
|
|
|
|
|
|
def place(job, bf, staging, dest, cancel, hint_name):
|
|
found = find_payloads(bf, staging)
|
|
placed = []
|
|
if not found:
|
|
jlog(job, "No encontre sce_sys ni imagenes: dejo todo junto en una carpeta")
|
|
found = [("folder", staging)]
|
|
for kind, path in found:
|
|
if cancel.is_set():
|
|
raise E.Cancelled()
|
|
name = posixpath.basename(path)
|
|
if kind == "game":
|
|
# Siempre TITLEID-app, venga suelto o dentro de una carpeta del RAR
|
|
tid = (E.title_id(bf.read_text(path + "/sce_sys/param.json"))
|
|
or E.title_id(name) or E.title_id(hint_name))
|
|
if tid:
|
|
name = tid + "-app"
|
|
elif path == staging:
|
|
name = E.safe_name(hint_name)
|
|
elif path == staging:
|
|
name = E.safe_name(hint_name)
|
|
target_dir = "/data/pkg" if kind == "pkg" else dest
|
|
target = target_dir + "/" + name
|
|
if kind in ("game", "image"):
|
|
warn_duplicates(job, bf, E.title_id(name), exclude=target)
|
|
if bf.exists(target):
|
|
raise E.PipeError("Ya existe %s. No lo piso: borralo o renombralo y pulsa Reintentar. "
|
|
"Lo extraido esta en %s" % (target, path))
|
|
bf.mkdir(target_dir)
|
|
bf.rename(path, target)
|
|
jlog(job, "Colocado: %s" % target)
|
|
placed.append(target)
|
|
if kind in ("game", "image"):
|
|
job["_placed_game"] = True
|
|
return placed
|
|
|
|
|
|
def fix_game_perms(job, bf, target, cancel=None):
|
|
"""Tras descomprimir con BFpilot: permisos 777 (si no, la PS5 no la arranca)."""
|
|
try:
|
|
if not bf.exists(target):
|
|
return
|
|
setj(job, phase="Arreglando permisos de %s" % posixpath.basename(target))
|
|
changed, seen, notable = bf.fix_perms(target, cancel, log=lambda m: jlog(job, m))
|
|
old = ", ".join("%s tenia %o" % (k, v) for k, v in list(notable.items())[:2])
|
|
if changed:
|
|
jlog(job, "Permisos arreglados (777) en %d de %d elementos%s" % (changed, seen + 1,
|
|
(": " + old) if old else ""))
|
|
else:
|
|
jlog(job, "Permisos ya correctos")
|
|
except E.Cancelled:
|
|
raise
|
|
except Exception as e:
|
|
jlog(job, "AVISO: no pude poner los permisos de %s (%s). Si no arranca, en FileZilla: "
|
|
"clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios." % (target, e))
|
|
|
|
|
|
def password_candidates(job, cfg):
|
|
cands = []
|
|
for p in ([job.get("password")] + list(cfg.get("passwords") or [])):
|
|
if p and p not in cands:
|
|
cands.append(p)
|
|
if cfg.get("guess_passwords", True):
|
|
for p in E.password_guesses([s["name"] for s in job["sources"]]):
|
|
if p not in cands:
|
|
cands.append(p)
|
|
return cands
|
|
|
|
|
|
def extract_job(job, info, cancel):
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
watch = start_space_watch(job, bf, cancel, "la descompresion")
|
|
try:
|
|
return _extract_job(job, info, cancel)
|
|
finally:
|
|
watch.set()
|
|
|
|
|
|
def _extract_job(job, info, cancel):
|
|
cfg = cfg_snapshot()
|
|
bf = E.BFpilot(cfg)
|
|
dest = job["dest"] or cfg["dest"]
|
|
if info["mode"] == "done":
|
|
for p in info.get("placed") or []:
|
|
jlog(job, "Colocado: %s" % p)
|
|
job["_placed_game"] = bool(info.get("game"))
|
|
return info.get("placed") or []
|
|
if info["mode"] == "pcx":
|
|
return finish_pc_extract(job, info, bf, dest, cancel, cfg)
|
|
wait_bfpilot(job, bf, cancel)
|
|
staging = "%s/extract/%s" % (WORK_ROOT, job["id"])
|
|
|
|
if info["mode"] == "file":
|
|
name = info["name"]
|
|
low = name.lower()
|
|
target_dir = "/data/pkg" if low.endswith(".pkg") else dest
|
|
target = target_dir + "/" + name
|
|
if bf.exists(target):
|
|
raise E.PipeError("Ya existe %s; no lo piso. Lo enviado esta en %s" % (target, info["path"]))
|
|
bf.mkdir(target_dir)
|
|
bf.rename(info["path"], target)
|
|
jlog(job, "Colocado: %s" % target)
|
|
try:
|
|
bf.delete(info["path"].rsplit("/", 1)[0], cancel)
|
|
except Exception:
|
|
pass
|
|
if low.endswith(".pkg"):
|
|
jlog(job, "Es un .pkg: instalalo con ps5upload (Install Package) desde /data/pkg.")
|
|
return [target]
|
|
|
|
src = info["dir"] + "/" + info["first"]
|
|
cands = password_candidates(job, cfg) + [""]
|
|
bf.mkdir(WORK_ROOT + "/extract")
|
|
if bf.exists(staging):
|
|
# BFpilot solo crea esta carpeta cuando la extraccion termina bien
|
|
jlog(job, "Ya estaba descomprimido de antes; paso a colocarlo")
|
|
cands = []
|
|
else:
|
|
for e in bf.listdir(WORK_ROOT + "/extract"):
|
|
if e["name"].startswith(job["id"] + ".bfpilot-extracting"):
|
|
jlog(job, "Borrando restos de un intento anterior: %s" % e["name"])
|
|
bf.delete(WORK_ROOT + "/extract/" + e["name"], cancel)
|
|
|
|
live = {"sig": None, "t": time.time(), "warned": False}
|
|
guard = {"t": 0.0}
|
|
|
|
def check_runaway(st):
|
|
"""Mide cuanto lleva escrito BFpilot y cuanto queda libre, para avisar a tiempo."""
|
|
total = int(st.get("totalBytes") or 0)
|
|
if total <= 0 or time.time() - guard["t"] < 10:
|
|
return
|
|
guard["t"] = time.time()
|
|
try:
|
|
# Solo la carpeta temporal de ESTA descompresion (BFpilot la dice en "stage").
|
|
# Antes se sumaban restos de intentos anteriores y saltaba en falso.
|
|
stage = st.get("stage") or ""
|
|
if not stage.startswith(WORK_ROOT + "/extract/") or ".bfpilot-extracting" not in stage:
|
|
return
|
|
size = bf.du(stage) or 0
|
|
free = free_space(bf)
|
|
guard["size"], guard["free"] = size, free
|
|
# Prevision: el progreso de BFpilot cuenta lo LEIDO del RAR, no lo escrito. Un volcado en
|
|
# carpeta va sin comprimir y puede ocupar varias veces el RAR (visto: 35 GB -> 145 GB).
|
|
# Regla prudente: hasta ~5 veces lo que ocupa el RAR.
|
|
guard["fits"] = None
|
|
if free is not None:
|
|
worst = total * 5
|
|
guard["final"] = max(worst, size)
|
|
# lo que aun puede crecer (al menos 10 GB de colchon aunque ya pase del estimado)
|
|
guard["fits"] = (free - margin_bytes()) > max(worst - size, 10 * GiB)
|
|
except Exception:
|
|
pass
|
|
|
|
def on_status(st):
|
|
pct = int(st.get("percent") or 0)
|
|
txt = []
|
|
if st.get("totalBytes"):
|
|
# OJO: BFpilot cuenta lo LEIDO del RAR, no el tamano final (que puede ser mucho mayor)
|
|
txt.append("leido del RAR %s de %s" % (human(st.get("bytesWritten") or 0), human(st["totalBytes"])))
|
|
if st.get("averageMBps"):
|
|
txt.append("%.0f MB/s" % float(st["averageMBps"]))
|
|
# Senal de vida: el porcentaje a veces solo sube al acabar cada archivo grande,
|
|
# pero lo leido/escrito y la CPU de BFpilot cambian aunque el % no se mueva.
|
|
sig = (st.get("bytesWritten"), st.get("inputBytes"), st.get("outputBytes"),
|
|
st.get("cpuTotalMs"), st.get("filesDone"), st.get("current"))
|
|
now = time.time()
|
|
if sig != live["sig"]:
|
|
live.update(sig=sig, t=now)
|
|
if live["warned"]:
|
|
jlog(job, "La descompresion vuelve a avanzar")
|
|
live["warned"] = False
|
|
if guard.get("size") is not None:
|
|
t = "descomprimido %s" % human(guard["size"])
|
|
if guard.get("free") is not None:
|
|
t += " · libre %s" % human(guard["free"])
|
|
if guard.get("fits") is False:
|
|
t += " · justo de espacio (en el peor caso podria llegar a ~%s)" % human(guard.get("final", 0))
|
|
elif guard.get("fits"):
|
|
t += " · hay sitio de sobra"
|
|
txt.append(t)
|
|
idle = now - live["t"]
|
|
if st.get("cpuUtilPercent") is not None and idle < 30:
|
|
txt.append("CPU %d%%" % float(st["cpuUtilPercent"]))
|
|
if idle >= 300:
|
|
txt.append("sin cambios desde hace %d min" % (idle // 60))
|
|
if not live["warned"]:
|
|
jlog(job, "Aviso: la descompresion no da senales de vida desde hace 5 min. "
|
|
"Mira en BFpilot (puerto 5905) si sigue 'running' antes de cancelar.")
|
|
live["warned"] = True
|
|
elif st.get("current"):
|
|
txt.append(str(st["current"])[-60:])
|
|
setj(job, extract_pct=pct, extract_text=" · ".join(txt))
|
|
check_runaway(st)
|
|
if guard.get("fits") is False and not guard.get("fit_logged"):
|
|
guard["fit_logged"] = True
|
|
jlog(job, "Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR (~%s) y quedan %s "
|
|
"libres. Puede bastar, pero si el espacio baja del margen lo paro. Si puedes, "
|
|
"libera espacio." % (human(guard.get("final", 0)), human(guard.get("free") or 0)))
|
|
|
|
last_err = None
|
|
final_st = None
|
|
for i, pw in enumerate(cands):
|
|
label = ("contrasena %d de %d" % (i + 1, len(cands) - 1)) if pw else "sin contrasena"
|
|
setj(job, status="extrayendo", phase="Descomprimiendo en la PS5 (%s)" % label,
|
|
extract_pct=0, extract_text="")
|
|
jlog(job, "Descomprimiendo %s (%s)" % (info["first"], label))
|
|
try:
|
|
final_st = bf.extract(src, staging, pw, cancel, on_status,
|
|
threads=cfg_snapshot().get("extract_threads", 0))
|
|
if pw:
|
|
jlog(job, "Contrasena correcta")
|
|
last_err = None
|
|
break
|
|
except E.PipeError as e:
|
|
last_err = e
|
|
jlog(job, "Fallo: %s" % e)
|
|
if bf.exists(staging):
|
|
bf.delete(staging, cancel)
|
|
if not E.is_password_error(str(e)):
|
|
break
|
|
if last_err is not None:
|
|
msg = str(last_err).lower()
|
|
if "aes" in msg:
|
|
raise E.PipeError("Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo (solo el cifrado "
|
|
"ZIP clasico). Los archivos ya estan en la PS5, en %s. Puedes bajarlos al PC, "
|
|
"extraerlos con 7-Zip y subir la carpeta." % info["dir"])
|
|
if "unsupported archive type" in msg and re.search(r"\.z\d{2}$|\.zip$", info["first"], re.I):
|
|
raise E.PipeError("BFpilot no abre ZIP partidos (.z01, .z02...). Uno los RAR/7z partidos, "
|
|
"pero este formato no. Los archivos estan en %s." % info["dir"])
|
|
if E.is_password_error(str(last_err)):
|
|
raise E.PipeError("Ninguna contrasena ha funcionado. Escribela en el trabajo o en Ajustes "
|
|
"y pulsa Reintentar (no hace falta volver a descargar).")
|
|
raise last_err
|
|
|
|
got = bf.du(staging)
|
|
if got is not None:
|
|
jlog(job, "Lo descomprimido ocupa %s" % human(got))
|
|
|
|
setj(job, status="colocando", phase="Colocando en su sitio", extract_pct=100)
|
|
hint = info["first"][:len(E.set_key(info["first"]))] # mismo nombre, sin cambiar mayusculas
|
|
placed = place(job, bf, staging, dest, cancel, hint)
|
|
for target in placed:
|
|
fix_game_perms(job, bf, target, cancel)
|
|
for leftover in (staging,):
|
|
if bf.exists(leftover):
|
|
try:
|
|
bf.delete(leftover, cancel)
|
|
except Exception as e:
|
|
jlog(job, "No pude borrar %s: %s" % (leftover, e))
|
|
if cfg.get("delete_archives", True):
|
|
jlog(job, "Borrando los comprimidos de la PS5")
|
|
try:
|
|
bf.delete(info["dir"], cancel)
|
|
except Exception as e:
|
|
jlog(job, "No pude borrar %s: %s" % (info["dir"], e))
|
|
else:
|
|
jlog(job, "Los comprimidos se quedan en %s" % info["dir"])
|
|
return placed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hilos de trabajo: uno envia, otro descomprime (pueden ir a la vez)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def stopped(job):
|
|
"""Un trabajo se ha parado: por falta de espacio, por pausa o porque lo cancelaste."""
|
|
with LOCK:
|
|
reason = job.pop("_stop", None)
|
|
paused = job.pop("_pause", False)
|
|
if reason:
|
|
setj(job, status="error", error=reason, phase="Error")
|
|
jlog(job, "ERROR: %s" % reason)
|
|
elif paused:
|
|
setj(job, status="pausado", phase="En pausa (pulsa Reanudar para seguir)")
|
|
jlog(job, "En pausa")
|
|
else:
|
|
setj(job, status="cancelado", phase="Cancelado")
|
|
|
|
|
|
def transfer_worker():
|
|
while True:
|
|
with WAKE:
|
|
job = None
|
|
while job is None:
|
|
for j in JOBS:
|
|
if j["status"] == "pendiente":
|
|
job = j
|
|
break
|
|
if job is None:
|
|
WAKE.wait()
|
|
cancel = CANCELS.setdefault(job["id"], threading.Event())
|
|
cancel.clear()
|
|
job["status"] = "comprobando"
|
|
try:
|
|
srcs = probe_job(job, cancel)
|
|
sets = split_into_sets(srcs)
|
|
if len(sets) > 1:
|
|
jlog(job, "Detectados %d archivos distintos: los separo en varios trabajos" % len(sets))
|
|
with LOCK:
|
|
idx = JOBS.index(job)
|
|
for k, extra in enumerate(sets[1:], 1):
|
|
nj = new_job([s.original for s in extra], job["password"], job["dest"],
|
|
where=job.get("where"), raw=job.get("raw"))
|
|
nj["sources"] = describe(extra)
|
|
nj["title"] = E.display_name(extra[0].name)
|
|
JOBS.insert(idx + k, nj)
|
|
job["lines"] = [s.original for s in sets[0]]
|
|
srcs = sets[0]
|
|
setj(job, sources=describe(srcs), title=job["title"] or E.display_name(srcs[0].name))
|
|
save_jobs()
|
|
info = transfer_job(job, srcs, cancel)
|
|
setj(job, info=info, status="en cola para descomprimir",
|
|
phase="Enviado. Esperando turno para descomprimir")
|
|
jlog(job, "Envio completo")
|
|
with WAKE:
|
|
EXTRACT_Q.append(job["id"])
|
|
WAKE.notify_all()
|
|
except E.Cancelled:
|
|
stopped(job)
|
|
except Exception as e:
|
|
msg = str(e)
|
|
if "perdida" in msg:
|
|
free = free_space(E.BFpilot(cfg_snapshot()))
|
|
if free is not None and free < margin_bytes():
|
|
msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free)
|
|
setj(job, status="error", error=msg, phase="Error")
|
|
jlog(job, "ERROR: %s" % msg)
|
|
record_history(job)
|
|
traceback.print_exc()
|
|
save_jobs()
|
|
|
|
|
|
def extract_worker():
|
|
while True:
|
|
with WAKE:
|
|
while not EXTRACT_Q:
|
|
WAKE.wait()
|
|
job = find(EXTRACT_Q.pop(0))
|
|
if job is None or job["status"] != "en cola para descomprimir":
|
|
continue
|
|
cancel = CANCELS.setdefault(job["id"], threading.Event())
|
|
try:
|
|
placed = extract_job(job, job["info"], cancel)
|
|
is_game = (job.pop("_placed_game", False)
|
|
or any(p.endswith("-app") or p.lower().endswith(E.IMAGE_EXTS) for p in placed)) \
|
|
and (job.get("dest", "").startswith("/data/homebrew") or "/homebrew" in job.get("dest", ""))
|
|
setj(job, status="hecho", result=placed, extract_text="",
|
|
phase=("Listo. ShadowMountPlus lo anadira al inicio en unos segundos" if is_game
|
|
else "Listo. Copiado en la PS5"))
|
|
jlog(job, "Terminado")
|
|
record_history(job)
|
|
except E.Cancelled:
|
|
stopped(job)
|
|
except Exception as e:
|
|
msg = str(e)
|
|
if "perdida" in msg:
|
|
free = free_space(E.BFpilot(cfg_snapshot()))
|
|
if free is not None and free < margin_bytes():
|
|
msg += " -- La PS5 solo tiene %s libres: seguramente se lleno el disco." % human(free)
|
|
setj(job, status="error", error=msg, phase="Error")
|
|
jlog(job, "ERROR: %s" % msg)
|
|
record_history(job)
|
|
traceback.print_exc()
|
|
save_jobs()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Servidor web local
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TOKEN = secrets.token_urlsafe(18)
|
|
|
|
|
|
def test_connection(cfg):
|
|
out = {"ftp": None, "bf": None, "free": None}
|
|
try:
|
|
f = E.Ftp(cfg).connect()
|
|
f.quit()
|
|
out["ftp"] = "ok"
|
|
except Exception as e:
|
|
out["ftp"] = "error: %s" % e
|
|
bf = E.BFpilot(cfg)
|
|
v = bf.alive()
|
|
out["bf"] = ("ok (%s)" % v) if v else "no responde (cargalo desde Payload Manager)"
|
|
if v:
|
|
try:
|
|
st = bf._req("GET", "/api/fs/stat", {"path": "/data"})
|
|
out["free"] = human(int(st.get("availableBytes") or 0))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Limpieza de restos en /data/ps5directo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _job_dirs():
|
|
"""Carpetas de trabajo que todavia usa algun trabajo de la cola -> titulo."""
|
|
used = {}
|
|
with LOCK:
|
|
jobs = [dict(j) for j in JOBS if j["status"] != "hecho"]
|
|
for j in jobs:
|
|
title = j.get("title") or "trabajo"
|
|
srcs = j.get("sources") or []
|
|
if srcs:
|
|
used["%s/incoming/%s" % (WORK_ROOT, E.safe_name(E.set_key(srcs[0]["name"])))] = title
|
|
used["%s/incoming/%s" % (WORK_ROOT, j["id"])] = title
|
|
used["%s/extract/%s" % (WORK_ROOT, j["id"])] = title
|
|
return used
|
|
|
|
|
|
def cleanup_scan():
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
if not bf.alive():
|
|
raise E.PipeError("BFpilot no responde: cargalo para poder buscar restos")
|
|
used = _job_dirs()
|
|
items = []
|
|
|
|
def add(path, label):
|
|
size = bf.du(path)
|
|
owner = None
|
|
for u, title in used.items():
|
|
if path == u or path.startswith(u + ".") or path.startswith(u + "/"):
|
|
owner = title
|
|
items.append({"path": path, "label": label, "size": size or 0, "in_use": owner})
|
|
|
|
try:
|
|
top = bf.listdir(WORK_ROOT)
|
|
except Exception:
|
|
top = []
|
|
for e in top:
|
|
n = e["name"]
|
|
if n in ("incoming", "extract") and e.get("dir"):
|
|
try:
|
|
sub = bf.listdir("%s/%s" % (WORK_ROOT, n))
|
|
except Exception:
|
|
sub = []
|
|
for x in sub:
|
|
add("%s/%s/%s" % (WORK_ROOT, n, x["name"]),
|
|
("Partes enviadas: " if n == "incoming" else "Descompresion a medias: ") + x["name"])
|
|
else:
|
|
add("%s/%s" % (WORK_ROOT, n), "Otro: " + n)
|
|
items.sort(key=lambda x: -x["size"])
|
|
return items
|
|
|
|
|
|
def cleanup_delete(paths):
|
|
with LOCK:
|
|
if any(j["status"] in ("extrayendo", "colocando") for j in JOBS):
|
|
raise E.PipeError("Espera a que termine la descompresion: BFpilot esta ocupado")
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
done = []
|
|
for p in paths:
|
|
p = posixpath.normpath(str(p))
|
|
if not p.startswith(WORK_ROOT + "/") or ".." in p:
|
|
continue # solo dentro de la carpeta de trabajo
|
|
bf.delete(p)
|
|
done.append(p)
|
|
return done
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test de velocidad: descarga sola, envio solo y los dos a la vez
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SPEEDTEST = {"running": False, "result": None}
|
|
|
|
|
|
def _dl_test(url, cfg, seconds, stop):
|
|
src = E.UrlSource(url).probe()
|
|
got = [0]
|
|
if not src.ranges or src.size <= 0:
|
|
gen = E._single(src.url, stop, lambda n: None)
|
|
else:
|
|
start = src.size // 3 # mitad del archivo: no siempre el principio cacheado
|
|
gen = E._ranged(src.url, start, src.size, cfg, stop, lambda n: None)
|
|
t0 = time.time()
|
|
try:
|
|
for data in gen:
|
|
got[0] += len(data)
|
|
if time.time() - t0 >= seconds or stop.is_set():
|
|
break
|
|
finally:
|
|
try:
|
|
gen.close()
|
|
except Exception:
|
|
pass
|
|
return got[0] / max(0.5, time.time() - t0)
|
|
|
|
|
|
def _up_test(cfg, seconds, stop):
|
|
path = WORK_ROOT + "/speedtest.tmp"
|
|
f = E.Ftp(cfg).connect()
|
|
try:
|
|
E.Ftp.mkdirs(f, WORK_ROOT)
|
|
conn = f.transfercmd("STOR " + path)
|
|
block = b"\0" * E.MiB
|
|
sent = 0
|
|
t0 = time.time()
|
|
with conn:
|
|
while time.time() - t0 < seconds and not stop.is_set():
|
|
conn.sendall(block)
|
|
sent += len(block)
|
|
el = time.time() - t0
|
|
try:
|
|
f.voidresp()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
f.delete(path)
|
|
except Exception:
|
|
pass
|
|
return sent / max(0.5, el)
|
|
finally:
|
|
try:
|
|
f.quit()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def run_speedtest(url, seconds=10):
|
|
"""Mide por separado para saber de quien es la culpa cuando va lento."""
|
|
cfg = cfg_snapshot()
|
|
res = {"url": bool(url), "dl": None, "up": None, "both_dl": None, "both_up": None, "pipe": None,
|
|
"error": None, "verdict": ""}
|
|
stop = threading.Event()
|
|
try:
|
|
if url:
|
|
res["dl"] = _dl_test(url, cfg, seconds, stop)
|
|
res["up"] = _up_test(cfg, seconds, stop)
|
|
if url:
|
|
box = {}
|
|
th = threading.Thread(target=lambda: box.update(d=_dl_test(url, cfg, seconds, stop)))
|
|
th.start()
|
|
box["u"] = _up_test(cfg, seconds, stop)
|
|
th.join()
|
|
res["both_dl"], res["both_up"] = box.get("d"), box["u"]
|
|
except Exception as e:
|
|
res["error"] = str(e)
|
|
mb = 1024 * 1024
|
|
dl, up, both = res["dl"], res["up"], res["both_up"]
|
|
v = []
|
|
if up is not None:
|
|
v.append("PC -> PS5 solo: %.1f MB/s." % (up / mb))
|
|
if up < 6 * mb:
|
|
v.append("Es bajo: el cuello es el Wi-Fi/cable entre PC y PS5 o el FTP de la consola.")
|
|
if dl is not None:
|
|
v.append("Descarga sola desde ese enlace: %.1f MB/s." % (dl / mb))
|
|
if dl < 5 * mb:
|
|
v.append("Es bajo: el alojamiento te esta dando poca velocidad ahora mismo "
|
|
"(limite de cuenta gratis, enlace castigado o servidor lento). No es tu red.")
|
|
if dl and up and both is not None and res["both_dl"] is not None:
|
|
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." % (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.")
|
|
elif pipe < 0.6 * ideal:
|
|
v.append("Estan compitiendo peor de lo normal: prueba el limite de velocidad en Ajustes "
|
|
"un poco por debajo de esa cifra, o un cable de red en el PC.")
|
|
else:
|
|
v.append("Es lo normal para una sola radio: compartir el aire tiene ese coste. "
|
|
"Solo un cable de red en el PC lo mejoraria de verdad.")
|
|
res["verdict"] = " ".join(v)
|
|
for k in ("dl", "up", "both_dl", "both_up", "pipe"):
|
|
if res[k] is not None:
|
|
res[k] = round(res[k] / mb, 2)
|
|
return res
|
|
|
|
|
|
def _speedtest_thread(url):
|
|
try:
|
|
SPEEDTEST["result"] = run_speedtest(url)
|
|
finally:
|
|
SPEEDTEST["running"] = False
|
|
|
|
|
|
def health_check():
|
|
"""Revision completa: que funciona, que no y que hacer."""
|
|
cfg = cfg_snapshot()
|
|
out = []
|
|
|
|
def add(name, ok, detail, fix=""):
|
|
out.append({"name": name, "ok": ok, "detail": detail, "fix": fix})
|
|
|
|
f = None
|
|
try:
|
|
f = E.Ftp(cfg).connect()
|
|
add("FTP de la PS5", True, "%s:%s responde" % (cfg["ps5_ip"], cfg["ftp_port"]))
|
|
try:
|
|
E.Ftp.mkdirs(f, WORK_ROOT)
|
|
path = WORK_ROOT + "/prueba_escritura.tmp"
|
|
import io
|
|
f.storbinary("STOR " + path, io.BytesIO(b"x" * 65536))
|
|
ok = E.Ftp.size(f, path) == 65536
|
|
try:
|
|
f.delete(path)
|
|
except Exception:
|
|
pass
|
|
add("Escribir en la PS5", ok, "Se puede escribir en %s" % WORK_ROOT if ok else "La prueba no se guardo bien",
|
|
"" if ok else "Revisa que el servidor FTP permita escribir en /data")
|
|
except Exception as e:
|
|
add("Escribir en la PS5", False, str(e), "El FTP no deja escribir: prueba a recargar el payload FTP")
|
|
except Exception as e:
|
|
add("FTP de la PS5", False, str(e),
|
|
"Comprueba la IP en Ajustes y que el payload FTP este cargado (puerto %s)" % cfg["ftp_port"])
|
|
finally:
|
|
try:
|
|
f and f.quit()
|
|
except Exception:
|
|
pass
|
|
exe = E.find_7z(cfg.get("sevenzip_path"))
|
|
where = cfg.get("extract_where") or "ps5"
|
|
add("7-Zip en el PC", bool(exe) or where != "pc", exe or "no instalado",
|
|
"" if exe else ("Hace falta para 'Descomprimir en el PC': instalalo desde 7-zip.org"
|
|
if where == "pc" else "Solo hace falta si eliges descomprimir en el PC"))
|
|
bf = E.BFpilot(cfg)
|
|
v = bf.alive()
|
|
add("BFpilot", bool(v), ("version %s" % v) if v else "no responde en el puerto %s" % cfg["bf_port"],
|
|
"" if v else "Cargalo desde Payload Manager: sin el no se puede descomprimir")
|
|
if v:
|
|
try:
|
|
sup = bf._req("GET", "/api/fs/archive/support")
|
|
add("Descompresion", True, "Formatos: %s" % ", ".join(sup.get("supported") or []))
|
|
except Exception as e:
|
|
add("Descompresion", False, str(e), "Actualiza BFpilot a la ultima version")
|
|
free = free_space(bf)
|
|
if free is not None:
|
|
margin = margin_bytes()
|
|
ok = free > margin + 10 * GiB
|
|
add("Espacio libre", ok, "%s libres (margen %s)" % (human(free), human(margin)),
|
|
"" if ok else "Queda poco: borra restos (Limpieza) o algo que no uses")
|
|
try:
|
|
st = bf._req("GET", "/api/fs/archive/status")
|
|
if st.get("state") in ("running", "prepared"):
|
|
add("Tarea en BFpilot", True, "Ahora mismo esta descomprimiendo: %s%%" % st.get("percent", "?"))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Actualizaciones: desde un .zip o desde GitHub
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UPDATE_EXTS = (".py", ".html", ".txt", ".bat", ".vbs", ".ico", ".js", ".json", ".png", ".css", ".md", ".svg")
|
|
|
|
|
|
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
|
|
import zipfile
|
|
if busy():
|
|
raise E.PipeError("Hay trabajos en marcha: espera a que terminen o pausalos antes de actualizar")
|
|
try:
|
|
z = zipfile.ZipFile(io.BytesIO(data))
|
|
except zipfile.BadZipFile:
|
|
raise E.PipeError("Ese archivo no es un .zip valido")
|
|
names = z.namelist()
|
|
roots = [n[:-len("app.py")] for n in names if n.endswith("app.py") and (n[:-len("app.py")] + "engine.py") in names]
|
|
if not roots:
|
|
raise E.PipeError("Ese .zip no es de PS5 Directo (no trae app.py y engine.py)")
|
|
root = min(roots, key=len)
|
|
m = re.search(r'VERSION = "([^"]+)"', z.read(root + "app.py").decode("utf-8", "replace"))
|
|
if not m:
|
|
raise E.PipeError("No encuentro la version dentro del .zip")
|
|
new_ver = m.group(1)
|
|
files = []
|
|
for n in names:
|
|
if not n.startswith(root) or n.endswith("/"):
|
|
continue
|
|
rel = n[len(root):]
|
|
parts = rel.replace("\\", "/").split("/")
|
|
if not rel or rel.startswith("/") or ".." in parts or ":" in rel:
|
|
continue
|
|
if not rel.lower().endswith(UPDATE_EXTS):
|
|
continue
|
|
files.append((n, rel))
|
|
# copia de seguridad de lo que se va a reemplazar
|
|
backup = os.path.join(HERE, ".backup", VERSION)
|
|
for _, rel in files:
|
|
cur = os.path.join(HERE, *rel.split("/"))
|
|
if os.path.isfile(cur):
|
|
dst = os.path.join(backup, *rel.split("/"))
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
with open(cur, "rb") as fi, open(dst, "wb") as fo:
|
|
fo.write(fi.read())
|
|
for n, rel in files:
|
|
dst = os.path.join(HERE, *rel.split("/"))
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
tmp = dst + ".nuevo"
|
|
with open(tmp, "wb") as fo:
|
|
fo.write(z.read(n))
|
|
os.replace(tmp, dst)
|
|
return new_ver
|
|
|
|
|
|
def restart_soon():
|
|
"""Arranca la version nueva y cierra esta (la nueva espera a que esta se cierre)."""
|
|
import subprocess
|
|
args = [sys.executable, os.path.join(HERE, "app.py"), "--after-update"]
|
|
kw = {"cwd": HERE}
|
|
if os.name == "nt":
|
|
kw["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
def go():
|
|
time.sleep(0.8)
|
|
try:
|
|
subprocess.Popen(args, **kw)
|
|
finally:
|
|
os._exit(0)
|
|
threading.Thread(target=go, daemon=True).start()
|
|
|
|
|
|
def update_repo():
|
|
with LOCK:
|
|
return (SETTINGS.get("update_repo") or DEFAULT_REPO or "").strip()
|
|
|
|
|
|
def github_latest(repo):
|
|
"""(version, url_del_zip, notas) de la ultima release de GitHub."""
|
|
repo = (repo or "").strip().strip("/")
|
|
if not re.match(r"^[\w.-]+/[\w.-]+$", repo):
|
|
raise E.PipeError("Pon el repositorio como usuario/nombre en Ajustes")
|
|
req = urllib.request.Request("https://api.github.com/repos/%s/releases/latest" % repo,
|
|
headers={"User-Agent": "PS5Directo/" + VERSION,
|
|
"Accept": "application/vnd.github+json"})
|
|
with urllib.request.urlopen(req, timeout=20) as r:
|
|
j = json.load(r)
|
|
url = None
|
|
for a in j.get("assets") or []:
|
|
if a.get("name", "").lower().endswith(".zip"):
|
|
url = a.get("browser_download_url")
|
|
break
|
|
url = url or j.get("zipball_url")
|
|
return (j.get("tag_name") or "").lstrip("vV"), url, (j.get("body") or "")[:2000]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enlaces capturados por la extension del navegador
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INBOX = [] # [{"url":..., "name":..., "t":...}]
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "PS5Directo"
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def _send(self, code, body, ctype="application/json; charset=utf-8"):
|
|
if isinstance(body, (dict, list)):
|
|
body = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
elif isinstance(body, str):
|
|
body = body.encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _auth(self):
|
|
if self.headers.get("X-Token") != TOKEN:
|
|
self._send(403, {"ok": False, "error": "token"})
|
|
return False
|
|
return True
|
|
|
|
def _body(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
if n > 5 * 1024 * 1024:
|
|
return {}
|
|
raw = self.rfile.read(n) if n else b"{}"
|
|
try:
|
|
return json.loads(raw.decode("utf-8"))
|
|
except ValueError:
|
|
return {}
|
|
|
|
def do_GET(self):
|
|
if self.path == "/logo.svg":
|
|
with open(os.path.join(HERE, "web", "logo.svg"), "rb") as f:
|
|
return self._send(200, f.read(), "image/svg+xml")
|
|
if self.path == "/favicon.ico":
|
|
with open(os.path.join(HERE, "web", "ps5directo.ico"), "rb") as f:
|
|
return self._send(200, f.read(), "image/x-icon")
|
|
if self.path in ("/", "/index.html") or self.path.startswith("/?"):
|
|
with open(os.path.join(HERE, "web", "index.html"), "rb") as f:
|
|
return self._send(200, f.read(), "text/html; charset=utf-8")
|
|
if self.path == "/api/ext/ping":
|
|
return self._send(200, {"ok": True, "app": "PS5 Directo", "version": VERSION})
|
|
if self.path == "/api/state":
|
|
if not self._auth():
|
|
return
|
|
if self.headers.get("X-UI"):
|
|
UI_SEEN["t"] = time.time()
|
|
with LOCK:
|
|
jobs = []
|
|
for j in JOBS:
|
|
d = {k: v for k, v in j.items() if k not in ("lines", "info", "password")}
|
|
d["has_password"] = bool(j.get("password"))
|
|
d["pcx"] = (j.get("info") or {}).get("mode") == "pcx"
|
|
jobs.append(d)
|
|
st = {"ok": True, "version": VERSION, "settings": dict(SETTINGS), "jobs": jobs,
|
|
"inbox": len(INBOX), "repo": SETTINGS.get("update_repo") or DEFAULT_REPO}
|
|
st["settings"].pop("ftp_pass", None)
|
|
return self._send(200, st)
|
|
self._send(404, {"ok": False})
|
|
|
|
def _from_extension(self):
|
|
o = self.headers.get("Origin") or ""
|
|
return o.startswith("chrome-extension://") or o.startswith("moz-extension://") \
|
|
or o.startswith("extension://")
|
|
|
|
def do_POST(self):
|
|
if self.path == "/api/ext/capture":
|
|
if not self._from_extension():
|
|
return self._send(403, {"ok": False, "error": "solo desde la extension"})
|
|
b = self._body()
|
|
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()})
|
|
del INBOX[:-200]
|
|
return self._send(200, {"ok": True})
|
|
if not self._auth():
|
|
return
|
|
b = self._body()
|
|
p = self.path
|
|
if p == "/api/open_ext_folder":
|
|
folder = os.path.join(HERE, "extension")
|
|
try:
|
|
if os.name == "nt":
|
|
os.startfile(folder)
|
|
return self._send(200, {"ok": True, "path": folder})
|
|
except Exception as e:
|
|
return self._send(200, {"ok": False, "path": folder, "error": str(e)})
|
|
if p == "/api/inbox/take":
|
|
with LOCK:
|
|
items = list(INBOX)
|
|
INBOX[:] = []
|
|
return self._send(200, {"ok": True, "items": items})
|
|
if p == "/api/update/zip":
|
|
import base64
|
|
try:
|
|
ver = install_zip(base64.b64decode(b.get("data") or ""))
|
|
except Exception as e:
|
|
return self._send(400, {"ok": False, "error": str(e)})
|
|
restart_soon()
|
|
return self._send(200, {"ok": True, "version": ver})
|
|
if p == "/api/update/check":
|
|
try:
|
|
ver, url, notes = github_latest(update_repo())
|
|
except Exception as e:
|
|
return self._send(200, {"ok": False, "error": str(e)})
|
|
return self._send(200, {"ok": True, "current": VERSION, "latest": ver, "notes": notes,
|
|
"newer": _ver_tuple(ver) > _ver_tuple(VERSION)})
|
|
if p == "/api/update/github":
|
|
try:
|
|
ver, url, _ = github_latest(update_repo())
|
|
if not url:
|
|
raise E.PipeError("Esa release no tiene un .zip")
|
|
req = urllib.request.Request(url, headers={"User-Agent": "PS5Directo/" + VERSION})
|
|
with urllib.request.urlopen(req, timeout=120) as r:
|
|
data = r.read(50 * 1024 * 1024)
|
|
ver = install_zip(data)
|
|
except Exception as e:
|
|
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():
|
|
if k in DEFAULTS:
|
|
if isinstance(DEFAULTS[k], bool):
|
|
v = bool(v)
|
|
elif isinstance(DEFAULTS[k], int):
|
|
try:
|
|
v = int(v)
|
|
except (TypeError, ValueError):
|
|
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})
|
|
if p == "/api/locate":
|
|
files = b.get("files") or []
|
|
return self._send(200, {"ok": True, "found": locate_files(files)})
|
|
if p == "/api/health":
|
|
return self._send(200, {"ok": True, "checks": health_check()})
|
|
if p == "/api/places":
|
|
bf = E.BFpilot(cfg_snapshot())
|
|
try:
|
|
bases = game_bases(bf) if bf.alive() else ["/data/homebrew"]
|
|
except Exception:
|
|
bases = ["/data/homebrew"]
|
|
return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]})
|
|
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p in ("/api/pick", "/api/history", "/api/history/clear"):
|
|
try:
|
|
return self._send(200, dict(ok=True, **fm_api(p, b)))
|
|
except E.PipeError as e:
|
|
return self._send(200, {"ok": False, "error": str(e)})
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
return self._send(200, {"ok": False, "error": str(e)})
|
|
if p == "/api/fixperms":
|
|
try:
|
|
return self._send(200, {"ok": True, "results": fix_all_perms()})
|
|
except Exception as e:
|
|
return self._send(500, {"ok": False, "error": str(e)})
|
|
if p == "/api/cleanup/scan":
|
|
try:
|
|
return self._send(200, {"ok": True, "items": cleanup_scan()})
|
|
except Exception as e:
|
|
return self._send(500, {"ok": False, "error": str(e)})
|
|
if p == "/api/cleanup/delete":
|
|
try:
|
|
return self._send(200, {"ok": True, "deleted": cleanup_delete(b.get("paths") or [])})
|
|
except Exception as e:
|
|
return self._send(500, {"ok": False, "error": str(e)})
|
|
if p == "/api/speedtest":
|
|
if SPEEDTEST["running"]:
|
|
return self._send(409, {"ok": False, "error": "Ya hay un test en marcha"})
|
|
with LOCK:
|
|
active = [j for j in JOBS if j["status"] in ("comprobando", "enviando")]
|
|
if active:
|
|
return self._send(409, {"ok": False, "error": "Pausa el envio antes del test (si no, se mezclan las medidas)"})
|
|
url = (b.get("url") or "").strip()
|
|
if url:
|
|
url = E.resolve_url(url)
|
|
SPEEDTEST.update(running=True, result=None)
|
|
threading.Thread(target=_speedtest_thread, args=(url,), daemon=True).start()
|
|
return self._send(200, {"ok": True})
|
|
if p == "/api/speedtest/result":
|
|
return self._send(200, {"ok": True, "running": SPEEDTEST["running"], "result": SPEEDTEST["result"]})
|
|
if p == "/api/test":
|
|
return self._send(200, {"ok": True, **test_connection(cfg_snapshot())})
|
|
if p == "/api/add":
|
|
lines = [x.strip() for x in str(b.get("lines", "")).splitlines() if x.strip()]
|
|
if not lines:
|
|
return self._send(400, {"ok": False, "error": "Pega al menos un enlace"})
|
|
dest = (b.get("dest") or SETTINGS["dest"]).rstrip("/") or "/data/homebrew"
|
|
if not dest.startswith("/data") and not dest.startswith("/mnt/"):
|
|
return self._send(400, {"ok": False, "error": "El destino debe estar en /data o /mnt"})
|
|
job = new_job(lines, (b.get("password") or "").strip(), dest, where=b.get("where"),
|
|
raw=bool(b.get("raw")))
|
|
with WAKE:
|
|
JOBS.append(job)
|
|
WAKE.notify_all()
|
|
save_jobs()
|
|
return self._send(200, {"ok": True, "id": job["id"]})
|
|
if p.startswith("/api/job/"):
|
|
parts = p.split("/")
|
|
if len(parts) != 5:
|
|
return self._send(404, {"ok": False})
|
|
job_id, action = parts[3], parts[4]
|
|
with WAKE:
|
|
job = find(job_id)
|
|
if not job:
|
|
return self._send(404, {"ok": False, "error": "no existe"})
|
|
if action == "pause":
|
|
if job["status"] == "pendiente":
|
|
job["status"] = "pausado"
|
|
job["phase"] = "En pausa (pulsa Reanudar para seguir)"
|
|
elif job["status"] in ("comprobando", "enviando"):
|
|
job["_pause"] = True
|
|
CANCELS.setdefault(job_id, threading.Event()).set()
|
|
elif action == "cancel":
|
|
CANCELS.setdefault(job_id, threading.Event()).set()
|
|
if job["status"] in ("pendiente", "en cola para descomprimir"):
|
|
job["status"] = "cancelado"
|
|
job["phase"] = "Cancelado"
|
|
elif action == "retry":
|
|
if job["status"] in ("error", "cancelado", "pausado"):
|
|
if b.get("password"):
|
|
job["password"] = b["password"].strip()
|
|
CANCELS.setdefault(job_id, threading.Event()).clear()
|
|
job["error"] = ""
|
|
if job.get("info") and job.get("phase") != "Cancelado" and \
|
|
job.get("sent", 0) >= max(0, job.get("total", 0)) and job.get("total", -1) > 0:
|
|
job["status"] = "en cola para descomprimir"
|
|
job["phase"] = "Reintentando la descompresion"
|
|
EXTRACT_Q.append(job_id)
|
|
else:
|
|
job["status"] = "pendiente"
|
|
job["phase"] = "En cola"
|
|
WAKE.notify_all()
|
|
elif action == "remove":
|
|
if job["status"] in ("hecho", "error", "cancelado", "pausado"):
|
|
JOBS.remove(job)
|
|
elif action in ("up", "down"):
|
|
i = JOBS.index(job)
|
|
k = i - 1 if action == "up" else i + 1
|
|
if 0 <= k < len(JOBS):
|
|
JOBS[i], JOBS[k] = JOBS[k], JOBS[i]
|
|
save_jobs()
|
|
return self._send(200, {"ok": True})
|
|
if p == "/api/clear":
|
|
with LOCK:
|
|
JOBS[:] = [j for j in JOBS if j["status"] not in ("hecho", "cancelado")]
|
|
save_jobs()
|
|
return self._send(200, {"ok": True})
|
|
if p == "/api/quit":
|
|
self._send(200, {"ok": True})
|
|
threading.Thread(target=lambda: (time.sleep(0.5), os._exit(0)), daemon=True).start()
|
|
return
|
|
self._send(404, {"ok": False})
|
|
|
|
|
|
SKIP_DIRS = {"windows", "program files", "program files (x86)", "programdata", "appdata",
|
|
"$recycle.bin", "system volume information", "node_modules", ".git",
|
|
"msocache", "recovery", "$windows.~bt", "$windows.~ws", "perflogs"}
|
|
|
|
|
|
def _search_roots():
|
|
home = os.path.expanduser("~")
|
|
roots = []
|
|
for sub in ("Downloads", "Descargas", "Desktop", "Escritorio", "Documents",
|
|
"Documentos", "Videos", "Vídeos", ""):
|
|
d = os.path.join(home, sub) if sub else home
|
|
if os.path.isdir(d) and d not in roots:
|
|
roots.append(d)
|
|
if os.name == "nt":
|
|
import string
|
|
for letter in string.ascii_uppercase:
|
|
d = letter + ":\\"
|
|
if os.path.isdir(d):
|
|
roots.append(d)
|
|
else:
|
|
roots.append("/")
|
|
return roots
|
|
|
|
|
|
def locate_files(files, budget=12.0, max_depth=6):
|
|
"""Busca en el PC archivos por nombre y tamano (los que se sueltan en el navegador)."""
|
|
want = {}
|
|
for f in files:
|
|
try:
|
|
want.setdefault(str(f["name"]).lower(), []).append(int(f["size"]))
|
|
except Exception:
|
|
pass
|
|
found = {}
|
|
t0 = time.time()
|
|
seen = set()
|
|
for root in _search_roots():
|
|
stack = [(root, 0)]
|
|
while stack and len(found) < len(want):
|
|
if time.time() - t0 > budget:
|
|
return found
|
|
path, depth = stack.pop()
|
|
key = os.path.normcase(path)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
try:
|
|
with os.scandir(path) as it:
|
|
entries = list(it)
|
|
except OSError:
|
|
continue
|
|
if True:
|
|
for e in entries:
|
|
try:
|
|
if e.is_dir(follow_symlinks=False):
|
|
n = e.name.lower()
|
|
if depth < max_depth and n not in SKIP_DIRS and not n.startswith("."):
|
|
stack.append((e.path, depth + 1))
|
|
elif e.name.lower() in want and e.name not in found:
|
|
if e.stat().st_size in want[e.name.lower()]:
|
|
found[e.name] = e.path
|
|
except OSError:
|
|
pass
|
|
if len(found) >= len(want):
|
|
break
|
|
return found
|
|
|
|
|
|
def pick_port():
|
|
for port in (8777, 8778, 8779, 0):
|
|
s = socket.socket()
|
|
try:
|
|
s.bind(("127.0.0.1", port))
|
|
p = s.getsockname()[1]
|
|
s.close()
|
|
return p
|
|
except OSError:
|
|
s.close()
|
|
return 0
|
|
|
|
|
|
SESSION_FILE = os.path.join(DATA_DIR, "session.json")
|
|
|
|
|
|
def dropped_paths():
|
|
"""Rutas de archivos arrastrados sobre el .bat o enviados con 'Enviar a'."""
|
|
out = []
|
|
for a in sys.argv[1:]:
|
|
if a.startswith("--"):
|
|
continue
|
|
a = a.strip().strip('"')
|
|
if os.path.isfile(a) or os.path.isdir(a):
|
|
out.append(os.path.abspath(a))
|
|
return out
|
|
|
|
|
|
def running_session():
|
|
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando."""
|
|
try:
|
|
with open(SESSION_FILE, encoding="utf-8") as f:
|
|
ses = json.load(f)
|
|
base = "http://127.0.0.1:%d" % int(ses["port"])
|
|
req = urllib.request.Request(base + "/api/state", headers={"X-Token": ses["token"]})
|
|
with urllib.request.urlopen(req, timeout=3) as r:
|
|
json.load(r)
|
|
return base, ses["token"]
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def forward_to_running(ses, paths):
|
|
base, token = ses
|
|
try:
|
|
req = urllib.request.Request(base + "/api/add",
|
|
data=json.dumps({"lines": "\n".join(paths)}).encode(),
|
|
headers={"X-Token": token, "Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
return json.load(r).get("ok", False)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _find_app_browser():
|
|
"""Edge (viene con Windows) o Chrome, para abrir la app en su propia ventana."""
|
|
if os.name != "nt":
|
|
return None
|
|
cands = []
|
|
for env in ("PROGRAMFILES(X86)", "PROGRAMFILES", "LOCALAPPDATA"):
|
|
root = os.environ.get(env)
|
|
if root:
|
|
cands.append(os.path.join(root, "Microsoft", "Edge", "Application", "msedge.exe"))
|
|
cands.append(os.path.join(root, "Google", "Chrome", "Application", "chrome.exe"))
|
|
for c in cands:
|
|
if os.path.isfile(c):
|
|
return c
|
|
return None
|
|
|
|
|
|
def open_window(url):
|
|
"""Abre la interfaz como ventana de aplicacion. Devuelve el proceso o None."""
|
|
exe = _find_app_browser()
|
|
if exe and "--browser" not in sys.argv:
|
|
import subprocess
|
|
profile = os.path.join(DATA_DIR, "ventana")
|
|
try:
|
|
return subprocess.Popen([exe, "--app=" + url, "--user-data-dir=" + profile,
|
|
"--window-size=1150,900", "--no-first-run",
|
|
"--no-default-browser-check"])
|
|
except Exception:
|
|
pass
|
|
webbrowser.open(url)
|
|
return None
|
|
|
|
|
|
def busy():
|
|
with LOCK:
|
|
return any(j["status"] not in ("hecho", "error", "cancelado", "pausado") for j in JOBS)
|
|
|
|
|
|
UI_SEEN = {"t": 0.0}
|
|
|
|
|
|
def watch_ui(grace=180):
|
|
"""Si la ventana lleva un rato cerrada y no hay nada en marcha, se cierra el programa.
|
|
Si hay trabajo, sigue en segundo plano; abre PS5 Directo otra vez para verlo."""
|
|
while True:
|
|
time.sleep(10)
|
|
seen = UI_SEEN["t"]
|
|
if seen and time.time() - seen > grace and not busy():
|
|
os._exit(0)
|
|
|
|
|
|
def main():
|
|
if sys.stdout is None: # abierto sin consola (pythonw): registro a archivo
|
|
try:
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
sys.stdout = sys.stderr = open(os.path.join(DATA_DIR, "ps5directo.log"), "a",
|
|
encoding="utf-8", buffering=1)
|
|
except Exception:
|
|
pass
|
|
paths = dropped_paths()
|
|
if "--after-update" in sys.argv:
|
|
for _ in range(60):
|
|
if not running_session():
|
|
break
|
|
time.sleep(0.5)
|
|
ses = running_session()
|
|
if ses:
|
|
if paths:
|
|
forward_to_running(ses, paths)
|
|
print("Anadido a la cola de PS5 Directo (ya estaba abierto)")
|
|
else:
|
|
open_window(ses[0] + "/#t=" + ses[1]) # ya funcionaba: solo enseno la ventana
|
|
sys.exit(3)
|
|
load_state()
|
|
if paths:
|
|
job = new_job(paths, "", SETTINGS["dest"])
|
|
with WAKE:
|
|
JOBS.append(job)
|
|
save_jobs()
|
|
for target in (transfer_worker, extract_worker):
|
|
threading.Thread(target=target, daemon=True).start()
|
|
port = pick_port()
|
|
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
try:
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
with open(SESSION_FILE, "w", encoding="utf-8") as f:
|
|
json.dump({"port": srv.server_address[1], "token": TOKEN}, f)
|
|
except Exception:
|
|
pass
|
|
url = "http://127.0.0.1:%d/#t=%s" % (srv.server_address[1], TOKEN)
|
|
print("PS5 Directo %s" % VERSION)
|
|
print("Abre en el navegador: %s" % url)
|
|
if "--no-browser" not in sys.argv:
|
|
threading.Timer(0.5, lambda: open_window(url)).start()
|
|
threading.Thread(target=watch_ui, daemon=True).start()
|
|
try:
|
|
srv.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|