Scrape: include libretro + OpenVGDB covers alongside IGDB

This commit is contained in:
2026-09-07 16:20:39 -04:00
parent 743d5e8e90
commit 5d619e5f59
4 changed files with 137 additions and 17 deletions
+97 -14
View File
@@ -466,6 +466,33 @@ def _ovgdb_name_list():
return _ovgdb_names return _ovgdb_names
_ovgdb_index = None
def _load_ovgdb_index():
"""Load {key: {"title", "url"}} written by app/openvgdb.py, or {}.
Looks in the runtime covers dir first, then the shipped ``data/covers``
copy (so a fresh install still shows readable titles without re-running
the full OpenVGDB scrape).
"""
global _ovgdb_index
if _ovgdb_index is not None:
return _ovgdb_index
_ovgdb_index = {}
for path in (
config.COVERS_DIR / "openvgdb-index.json",
config.LEGACY_COVERS_DIR / "openvgdb-index.json",
):
if path.exists():
try:
_ovgdb_index = json.loads(path.read_text(encoding="utf-8"))
break
except Exception: # noqa: BLE001
_ovgdb_index = {}
return _ovgdb_index
def fetch_openvgdb_local(name): def fetch_openvgdb_local(name):
"""Return a bundled OpenVGDB cover image for `name`, or None.""" """Return a bundled OpenVGDB cover image for `name`, or None."""
names = _ovgdb_name_list() names = _ovgdb_name_list()
@@ -1028,6 +1055,45 @@ def _search_libretro(query, limit):
return results return results
def _search_openvgdb(query, limit):
"""Return [{title, source, thumb_url, image_url}] from the offline OpenVGDB
bundle, using the display titles stored in openvgdb-index.json."""
names = _ovgdb_name_list()
if not names:
return []
index = _load_ovgdb_index()
db = _norm(query)
if not db:
return []
scored = []
for key in names:
if key == db:
score = 5
elif len(key) >= 4 and key.startswith(db):
score = 4
elif len(key) >= 4 and db in key:
score = 3
elif len(db) >= 4 and len(key) >= 4 and key in db:
score = 2
else:
continue
info = index.get(key) or {}
title = info.get("title") or key
scored.append((score, title, key))
scored.sort(key=lambda t: (-t[0], t[1]))
return [
{
"id": None,
"title": title,
"source": "openvgdb",
"thumb_url": "",
"image_url": "",
"_key": key,
}
for _score, title, key in scored[:limit]
]
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Game metadata (details sidebar) # Game metadata (details sidebar)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1411,29 +1477,46 @@ def movie_metadata_from_wikipedia(page_title, timeout=10):
def search_metadata_candidates(system, query, limit=20, timeout=10): def search_metadata_candidates(system, query, limit=20, timeout=10):
"""Search candidates the user can link an item to.""" """Search candidates the user can link an item to.
Games: IGDB (rich metadata) first, then libretro box art and OpenVGDB
(GameFAQs) box art so covers can be scraped even without an IGDB key.
"""
if system == "videos": if system == "videos":
return search_movies(query, limit, timeout) return search_movies(query, limit, timeout)
results = search_games_igdb(query, limit, timeout) results = []
if results: seen = set()
return results
# No IGDB results (or no key): fall back to libretro catalog names so the def add(r):
# user can still link to the correct entry (title-only). key = (r.get("source"), _norm(r.get("title") or ""))
results = _search_libretro(query, limit) if key[1] and key not in seen:
for r in results: seen.add(key)
r["source"] = "libretro" results.append(r)
r["id"] = None
return results for r in search_games_igdb(query, limit, timeout):
add(r)
for r in _search_libretro(query, limit):
r.setdefault("id", None)
add(r)
for r in _search_openvgdb(query, limit):
add(r)
return results[:limit]
def link_metadata(system, candidate, timeout=10): def link_metadata(system, candidate, timeout=10):
"""Fetch full metadata for a search candidate the user picked.""" """Fetch full metadata for a search candidate the user picked."""
if candidate.get("source") == "wikipedia": if candidate.get("source") == "wikipedia":
return movie_metadata_from_wikipedia(candidate.get("title") or "", timeout) return movie_metadata_from_wikipedia(candidate.get("title") or "", timeout)
if candidate.get("source") == "libretro": if candidate.get("source") in ("libretro", "openvgdb"):
# Catalog-name link only: no metadata backend, just the correct title. # Cover repositories have no metadata backend; try IGDB by title so the
# user still gets synopsis/genre/etc. when a key is configured.
title = candidate.get("title") or ""
if title:
meta = get_game_metadata_igdb(title, timeout)
if meta:
return meta
return { return {
"title": candidate.get("title") or "", "title": title,
"release_date": "", "release_date": "",
"overview": "", "overview": "",
"genres": [], "genres": [],
+24 -3
View File
@@ -824,8 +824,17 @@ class ScrapeSearchWorker(QObject):
def run(self): def run(self):
found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10) found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10)
for r in found: for r in found:
url = r.get("thumb_url") src = r.get("source")
r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None if src == "openvgdb":
r["thumb_bytes"] = covers.fetch_openvgdb_local(r.get("title") or "")
elif src == "libretro":
data = covers.fetch_libretro_local(r.get("title") or "")
r["thumb_bytes"] = data if data else (
covers.download_image(r.get("thumb_url"), timeout=8) if r.get("thumb_url") else None
)
else:
url = r.get("thumb_url")
r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None
self.results.emit(found) self.results.emit(found)
self.finished.emit() self.finished.emit()
@@ -849,6 +858,17 @@ class ScrapeFetchWorker(QObject):
def _scrape_cover(system, candidate, rid, timeout=20): def _scrape_cover(system, candidate, rid, timeout=20):
"""Download the cover for a metadata candidate; returns bytes or None.""" """Download the cover for a metadata candidate; returns bytes or None."""
src = candidate.get("source")
title = candidate.get("title") or ""
# Cover repositories ship offline bundles — read them directly (no network).
if src == "openvgdb" and title:
data = covers.fetch_openvgdb_local(title)
if data:
return data
if src == "libretro" and title:
data = covers.fetch_libretro_local(title)
if data:
return data
url = candidate.get("image_url") url = candidate.get("image_url")
if url: if url:
data = covers.download_image(url, timeout) data = covers.download_image(url, timeout)
@@ -858,7 +878,6 @@ def _scrape_cover(system, candidate, rid, timeout=20):
if candidate.get("source") == "igdb": if candidate.get("source") == "igdb":
return covers.compose_game_cover(data) return covers.compose_game_cover(data)
return data return data
title = candidate.get("title") or ""
if title: if title:
path = covers.fetch_cover(title, rid, system, timeout=timeout) path = covers.fetch_cover(title, rid, system, timeout=timeout)
if path is not None: if path is not None:
@@ -955,6 +974,8 @@ class ScrapeDialog(QDialog):
icon = QIcon(pm) icon = QIcon(pm)
year = r.get("year") or (r.get("release_date") or "")[:4] year = r.get("year") or (r.get("release_date") or "")[:4]
label = f"{r['title']} ({year})" if year else r["title"] label = f"{r['title']} ({year})" if year else r["title"]
if r.get("source") not in (None, "igdb", "tmdb"):
label += f" [{r['source']}]"
item = QListWidgetItem(icon, label) item = QListWidgetItem(icon, label)
if r.get("overview"): if r.get("overview"):
item.setToolTip(r["overview"]) item.setToolTip(r["overview"])
+15
View File
@@ -17,6 +17,7 @@ tags removed). The GUI reads this bundle with no network access.
""" """
import argparse import argparse
import concurrent.futures import concurrent.futures
import json
import logging import logging
import os import os
import re import re
@@ -94,6 +95,19 @@ def build_index(sqlite_path):
return best return best
def write_index(index, path=None):
"""Persist {key: {"title", "url"}} so the GUI can show readable titles when
searching the offline bundle (see covers._search_openvgdb)."""
path = Path(path) if path else (config.COVERS_DIR / "openvgdb-index.json")
data = {k: {"title": t, "url": u} for k, (t, u, _r) in index.items()}
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
except OSError as exc: # noqa: BLE001
log.warning("could not write index %s: %s", path, exc)
return path
def _existing_keys(): def _existing_keys():
"""Titles already present in the bundle (for resume).""" """Titles already present in the bundle (for resume)."""
if not BUNDLE.exists(): if not BUNDLE.exists():
@@ -152,6 +166,7 @@ def scrape(limit=None, workers=8, rate=3.0):
index = build_index(sqlite_path) index = build_index(sqlite_path)
log.info("indexed %d unique PSP covers", len(index)) log.info("indexed %d unique PSP covers", len(index))
write_index(index)
existing = _existing_keys() existing = _existing_keys()
todo = [(k, v) for k, v in sorted(index.items()) if k not in existing] todo = [(k, v) for k, v in sorted(index.items()) if k not in existing]
File diff suppressed because one or more lines are too long