Files
2026-09-06 22:30:13 -04:00

194 lines
6.7 KiB
Python

"""PSP save-data inventory and two-way sync (PSP / PPSSPP / cloud folder).
PSP saves live in ``PSP/SAVEDATA/<TITLE_ID>/`` folders (``PARAM.SFO`` + data).
PPSSPP uses the exact same layout under its own "memory stick" directory, so a
save folder can be copied verbatim between the PSP, PPSSPP, and a cloud folder.
"""
import hashlib
import sqlite3
from pathlib import Path
from . import config, iso_reader
from .db import get_connection
SAVEDATA_NAME = "SAVEDATA"
def default_ppsspp_saves():
"""Best-guess PPSSPP SAVEDATA directory (Linux-first, falls back to a default)."""
home = Path.home()
candidates = [
home / ".config" / "ppsspp" / "PSP" / SAVEDATA_NAME,
home / ".local" / "share" / "ppsspp" / "PSP" / SAVEDATA_NAME,
home / ".config" / "ppsspp" / SAVEDATA_NAME,
home / ".var" / "app" / "org.ppsspp.PPSSPP" / "data" / "ppsspp" / "PSP" / SAVEDATA_NAME,
home / "Documents" / "PPSSPP" / "PSP" / SAVEDATA_NAME,
]
for cand in candidates:
if cand.is_dir():
return cand
return candidates[0]
def read_save_meta(folder):
"""Return {title, detail, size, mtime, encrypted} for one save folder."""
folder = Path(folder)
title, detail = "", ""
sfo_path = folder / "PARAM.SFO"
if sfo_path.is_file():
try:
sfo = iso_reader.parse_sfo(sfo_path.read_bytes())
except Exception: # noqa: BLE001
sfo = {}
title = sfo.get("SAVEDATA_TITLE") or sfo.get("TITLE") or ""
detail = sfo.get("SAVEDATA_DETAIL") or ""
size = 0
mtime = 0.0
mtime_ns = 0
encrypted = False
for p in folder.rglob("*"):
if not p.is_file():
continue
if p.name.upper() == "SECURE.BIN":
encrypted = True
try:
st = p.stat()
except OSError:
continue
size += st.st_size
mtime = max(mtime, st.st_mtime)
mtime_ns = max(mtime_ns, st.st_mtime_ns)
return {"title": title or folder.name, "detail": detail, "size": size,
"mtime": mtime, "mtime_ns": mtime_ns, "encrypted": encrypted}
def list_saves(root):
"""Return [{id, path, title, detail, size, mtime, encrypted}] for a SAVEDATA dir."""
root = Path(root) if root else None
if root is None or not root.is_dir():
return []
out = []
for folder in sorted(root.iterdir()):
if not folder.is_dir():
continue
meta = read_save_meta(folder)
meta["id"] = folder.name
meta["path"] = str(folder)
out.append(meta)
return out
_sig_cache = {} # (save_id, size, mtime_ns) -> hexdigest
_cache_loaded = False
def _load_cache():
"""Populate the signature cache from the library DB (once per process)."""
global _cache_loaded
if _cache_loaded:
return
_cache_loaded = True
try:
conn = get_connection(config.DB_PATH)
conn.execute(
"CREATE TABLE IF NOT EXISTS save_sigs ("
" save_id TEXT NOT NULL, size INTEGER NOT NULL, mtime_ns INTEGER NOT NULL,"
" sig TEXT NOT NULL, PRIMARY KEY (save_id, size, mtime_ns))"
)
for r in conn.execute("SELECT save_id, size, mtime_ns, sig FROM save_sigs"):
_sig_cache[(r["save_id"], r["size"], r["mtime_ns"])] = r["sig"]
conn.close()
except sqlite3.Error:
pass
def save_signature(folder, size=None, mtime_ns=None):
"""Content fingerprint of a save folder (paths + bytes), mtime-independent.
Two saves with identical files count as the same "version" even when their
modification times differ (after a copy, or across filesystems). Results
are cached by ``(save_id, size, mtime_ns)`` in memory and persisted to the
library DB, so an unchanged save is never re-hashed — even after a restart.
"""
folder = Path(folder)
save_id = folder.name
if size is None or mtime_ns is None:
meta = read_save_meta(folder)
size = meta["size"]
mtime_ns = meta["mtime_ns"]
_load_cache()
key = (save_id, size, mtime_ns)
cached = _sig_cache.get(key)
if cached is not None:
return cached
h = hashlib.sha256()
for p in sorted(folder.rglob("*")):
if not p.is_file():
continue
h.update(p.relative_to(folder).as_posix().encode("utf-8", "replace"))
h.update(b"\0")
try:
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
except OSError: # noqa: BLE001
pass
h.update(b"\0")
sig = h.hexdigest()
_sig_cache[key] = sig
try:
conn = get_connection(config.DB_PATH)
conn.execute(
"INSERT OR REPLACE INTO save_sigs (save_id, size, mtime_ns, sig) VALUES (?,?,?,?)",
(save_id, size, mtime_ns, sig),
)
conn.commit()
conn.close()
except sqlite3.Error:
pass
return sig
def sync_plan(locations):
"""Converge every location to the newest *version* of each save.
``locations`` is ``{name: root_path}`` (empty/missing roots are ignored).
Saves are fingerprinted by content, so a location whose copy is already the
same version is never re-synced (mtime differences are ignored).
Returns a list of ``{"src": path, "dst_root": path, "dst_name": save_id}``.
"""
per_loc = {name: {s["id"]: s for s in list_saves(root)} for name, root in locations.items() if root}
active = [loc for loc, root in locations.items() if root]
all_ids = set()
for saves_map in per_loc.values():
all_ids.update(saves_map)
plan = []
for sid in sorted(all_ids):
entries = {}
for loc in active:
save = per_loc[loc].get(sid)
if save is not None:
save["sig"] = save_signature(save["path"], save["size"], save["mtime_ns"])
entries[loc] = save
# Group existing copies into distinct versions by content signature.
versions = {}
for save in entries.values():
versions.setdefault(save["sig"], []).append(save)
# The newest version wins (best-effort tiebreak by mtime).
newest = max(versions.values(), key=lambda group: max(s["mtime"] for s in group))[0]
# Replace any copy that is a different version.
for loc, save in entries.items():
if save["sig"] != newest["sig"]:
plan.append({"src": newest["path"], "dst_root": locations[loc], "dst_name": sid})
# Fill locations that don't have this save at all.
for loc in active:
if sid not in per_loc[loc]:
plan.append({"src": newest["path"], "dst_root": locations[loc], "dst_name": sid})
return plan