Scrape: include libretro + OpenVGDB covers alongside IGDB
This commit is contained in:
+97
-14
@@ -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
@@ -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"])
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user