Unbundle covers: fetch psppt-covers.zip from repo on demand with user prompt
build / Linux x86_64 (push) Successful in 2m28s
build / Windows x64 (Wine) (push) Successful in 3m8s

This commit is contained in:
2026-09-14 00:03:09 -04:00
parent 96e876b8ec
commit 38a3859a21
9 changed files with 261 additions and 58 deletions
+73 -33
View File
@@ -53,8 +53,20 @@ _TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
_MANUAL_DIR = config.USER_DATA_DIR / "manual_covers"
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
# Offline cover bundles live in the writable cover dir (downloaded on demand),
# not the read-only app bundle. A source checkout keeps them in data/covers/.
_OVGDB_ZIP = config.COVERS_DIR / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.COVERS_DIR / "libretro-covers.zip"
_BUNDLE_ZIP = config.COVERS_DIR / "psppt-covers.zip"
def _bundle_zip_path(name):
"""Resolve a cover bundle zip: writable dir first, legacy checkout second."""
for base in (config.COVERS_DIR, config.LEGACY_COVERS_DIR):
p = base / name
if p.exists():
return p
return config.COVERS_DIR / name
_WIKI_SEARCH = "https://en.wikipedia.org/w/api.php"
_WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
@@ -158,48 +170,74 @@ def clear_cover_cache():
pass
def _download_file(url, dest, timeout=600):
"""Stream `url` to `dest` (atomic); returns True on success."""
def _download_file(url, dest, timeout=600, progress_cb=None, cancel_event=None):
"""Stream `url` to `dest` (atomic); returns True on success.
``progress_cb(done, total)`` is called with bytes so far (total is 0 when
the server doesn't report a content length). ``cancel_event`` aborts the
download when set.
"""
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return False
total = int(resp.headers.get("Content-Length") or 0)
tmp = dest.with_name(dest.name + ".tmp")
done = 0
with open(tmp, "wb") as f:
while True:
if cancel_event is not None and cancel_event.is_set():
raise InterruptedError("download cancelled")
chunk = resp.read(1 << 20)
if not chunk:
break
f.write(chunk)
done += len(chunk)
if progress_cb is not None:
progress_cb(done, total)
os.replace(tmp, dest)
return True
def ensure_cover_bundles(server_url=""):
"""Download missing offline cover bundle zips from the store server.
def covers_bundle_present():
"""True when the offline cover bundle is installed (both inner zips)."""
return (_bundle_zip_path("libretro-covers.zip").exists()
and _bundle_zip_path("openvgdb-covers.zip").exists())
``server_url`` is the base URL (e.g. ``http://192.168.10.100:8000``); the
zips are fetched from ``<base>/api/covers/<name>.zip`` unless a direct URL
is set in config. Returns the names of bundles downloaded.
def download_covers_bundle(url=None, progress_cb=None, cancel_event=None):
"""Download and extract the combined cover bundle into the cover dir.
``url`` defaults to ``config.COVERS_BUNDLE_URL``. Raises on failure; the
caller decides whether to surface the error.
"""
base = (server_url or config.STORE_SERVER_URL or "").rstrip("/")
downloaded = []
for name, path, direct in (
("libretro-covers.zip", _LIBRETRO_ZIP, config.LIBRETRO_COVERS_URL),
("openvgdb-covers.zip", _OVGDB_ZIP, config.OVGDB_COVERS_URL),
):
if path.exists():
continue
url = direct or (f"{base}/api/covers/{name}" if base else "")
if not url:
continue
try:
if _download_file(url, path):
downloaded.append(name)
log.info("downloaded offline cover bundle %s", name)
except Exception as exc: # noqa: BLE001
log.warning("could not download cover bundle %s: %s", name, exc)
return downloaded
url = (url or config.COVERS_BUNDLE_URL or "").strip()
if not url:
raise ValueError("no cover bundle URL configured")
config.COVERS_DIR.mkdir(parents=True, exist_ok=True)
_download_file(url, _BUNDLE_ZIP, timeout=3600,
progress_cb=progress_cb, cancel_event=cancel_event)
inner = ("libretro-covers.zip", "openvgdb-covers.zip")
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
missing = [n for n in inner if n not in z.namelist()]
if missing:
raise ValueError(f"cover bundle is missing: {', '.join(missing)}")
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
for name in inner:
out = config.COVERS_DIR / name
tmp = out.with_name(out.name + ".tmp")
with z.open(name) as src, open(tmp, "wb") as dst:
while True:
chunk = src.read(1 << 20)
if not chunk:
break
dst.write(chunk)
os.replace(tmp, out)
# Invalidate cached namelists so lookups pick up the freshly extracted zips.
global _libretro_names, _ovgdb_names
_libretro_names = None
_ovgdb_names = None
return config.COVERS_DIR
def cover_url(name, kind="Named_Boxarts", system="games"):
@@ -482,9 +520,10 @@ def _ovgdb_name_list():
global _ovgdb_names
if _ovgdb_names is None:
_ovgdb_names = set()
if _OVGDB_ZIP.exists():
path = _bundle_zip_path("openvgdb-covers.zip")
if path.exists():
try:
with zipfile.ZipFile(_OVGDB_ZIP) as z:
with zipfile.ZipFile(path) as z:
_ovgdb_names = {n[:-4] for n in z.namelist() if n.endswith(".jpg")}
except Exception: # noqa: BLE001
pass
@@ -535,7 +574,7 @@ def fetch_openvgdb_local(name):
if entry is None:
return None
try:
with zipfile.ZipFile(_OVGDB_ZIP) as z:
with zipfile.ZipFile(_bundle_zip_path("openvgdb-covers.zip")) as z:
data = z.read(entry)
return data if _is_image(data) else None
except Exception: # noqa: BLE001
@@ -559,7 +598,7 @@ def fetch_libretro_local(name):
if entry is None:
return None
try:
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
with zipfile.ZipFile(_bundle_zip_path("libretro-covers.zip")) as z:
data = z.read(entry)
return data if _is_image(data) else None
except Exception: # noqa: BLE001
@@ -570,9 +609,10 @@ def _libretro_name_list():
global _libretro_names
if _libretro_names is None:
_libretro_names = set()
if _LIBRETRO_ZIP.exists():
path = _bundle_zip_path("libretro-covers.zip")
if path.exists():
try:
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
with zipfile.ZipFile(path) as z:
_libretro_names = {n[:-4] for n in z.namelist() if n.endswith(".png")}
except Exception: # noqa: BLE001
pass