Files
psp-preservation-tool/app/covers.py
T
quoije fb2a98dd21 ps1: embed real game art (ICON0 + PIC1) in converted EBOOTs
Fetch the game's cover art (libretro / ScreenScraper / IGDB / Wikipedia,
reusing the local cover cache) and bake it into the EBOOT's ICON0 (144x80
XMB icon) and PIC1 (480x272 launch background) instead of the default PS1
icon. Best-effort: falls back to the base icon/empty background when no art
is found. convert()/convert_to_folder() gain optional icon0/pic1 params.
2026-09-07 18:39:29 -04:00

1704 lines
57 KiB
Python

"""Cover art sources.
Games: libretro-thumbnails box art (no screenshot fallback) plus IGDB and
ScreenScraper box art when credentials are configured. UMD videos: manual
covers only (no public API hosts UMD-video box art) — drop images into the
manual covers folder.
Manual covers folder: ``<data>/manual_covers/<games|videos>/<Title>.png``
"""
import json
import logging
import os
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
from . import config, sources
from .db import get_connection
log = logging.getLogger("covers")
_LIBRETRO_KINDS = ("Named_Boxarts",)
_PLATFORMS = {
"games": {"libretro_repo": "Sony_-_PlayStation_Portable", "igdb_id": 38, "ss_id": "61"},
"ps1": {"libretro_repo": "Sony_-_PlayStation", "igdb_id": 7, "ss_id": "57"},
}
def _platform(system):
"""Platform parameters (libretro repo, IGDB id, ScreenScraper id)."""
return _PLATFORMS.get(system) or _PLATFORMS["games"]
def _libretro_base(system="games"):
return ("https://raw.githubusercontent.com/libretro-thumbnails/"
f"{_platform(system)['libretro_repo']}/master")
def _libretro_tree_url(system="games"):
return ("https://api.github.com/repos/libretro-thumbnails/"
f"{_platform(system)['libretro_repo']}/git/trees/master?recursive=1")
_SS_API = "https://api.screenscraper.fr/api2/jeuInfos.php"
_IGDB_TOKEN_URL = "https://id.twitch.tv/oauth2/token"
_IGDB_API = "https://api.igdb.com/v4/games"
_IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
_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.DATA_DIR / "manual_covers"
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
_WIKI_SEARCH = "https://en.wikipedia.org/w/api.php"
_WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
_JPEG_MAGIC = b"\xff\xd8\xff"
_MAX_HOPS = 3 # follow libretro symlink text bodies at most this many times
_UA = {"User-Agent": "psp-preserve/0.1 (https://github.com; local tool)"}
_igdb_token_state = {"token": "", "expires": 0.0} # IGDB OAuth2 token cache
_igdb_lock = threading.Lock()
_igdb_last = 0.0 # monotonic clock of the last IGDB API call
_index_state = {} # system -> {"ts", "names", "norm"}
_INDEX_TTL = 86400.0 # refresh the libretro file index once a day
_neg_cache = {} # cache key -> time.time() of the last failed fetch
_NEG_TTL = 1800.0 # skip re-fetching a missing cover for 30 minutes
# Persistent "no cover" cache: a DB-backed record of covers that failed so a
# restart or re-scan doesn't re-hit the network for items we already know are
# missing. Loaded lazily into memory, then queried in O(1).
_miss_db = {} # (key, system) -> time.time() of last miss
_miss_loaded = False
_MISS_TTL = 86400.0 # retry a missing cover after 1 day
def _load_miss_db():
global _miss_loaded
if _miss_loaded:
return
_miss_loaded = True
try:
conn = get_connection(config.DB_PATH)
for r in conn.execute(
"SELECT key, system, ts FROM cover_attempts WHERE status = 'missing'"
):
_miss_db[(r["key"], r["system"])] = r["ts"]
conn.close()
except Exception: # noqa: BLE001
pass
def _mark_missing(key, system):
ts = time.time()
_miss_db[(key, system)] = ts
try:
conn = get_connection(config.DB_PATH)
conn.execute(
"INSERT INTO cover_attempts (key, system, status, ts) VALUES (?,?,?,?) "
"ON CONFLICT(key, system) DO UPDATE SET status=excluded.status, ts=excluded.ts",
(key, system, "missing", ts),
)
conn.commit()
conn.close()
except Exception: # noqa: BLE001
pass
def _clear_missing(key, system):
_miss_db.pop((key, system), None)
try:
conn = get_connection(config.DB_PATH)
conn.execute(
"DELETE FROM cover_attempts WHERE key = ? AND system = ?", (key, system)
)
conn.commit()
conn.close()
except Exception: # noqa: BLE001
pass
def clear_miss_cache():
"""Forget all recorded cover misses so they are retried on the next load."""
global _miss_loaded
_neg_cache.clear()
_miss_db.clear()
_miss_loaded = True
try:
conn = get_connection(config.DB_PATH)
conn.execute("DELETE FROM cover_attempts")
conn.commit()
conn.close()
except Exception: # noqa: BLE001
pass
def clear_cover_cache():
"""Delete all cached cover images (keeps the libretro index) so every
cover is re-fetched on the next refresh."""
clear_miss_cache()
if not config.COVERS_DIR.is_dir():
return
for root, _dirs, files in os.walk(config.COVERS_DIR):
for name in files:
if name.upper().startswith("UMD_COVER_TEMPLATE"):
continue # keep the compositing template asset
if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
try:
(Path(root) / name).unlink()
except OSError:
pass
def _download_file(url, dest, timeout=600):
"""Stream `url` to `dest` (atomic); returns True on success."""
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return False
tmp = dest.with_name(dest.name + ".tmp")
with open(tmp, "wb") as f:
while True:
chunk = resp.read(1 << 20)
if not chunk:
break
f.write(chunk)
os.replace(tmp, dest)
return True
def ensure_cover_bundles(server_url=""):
"""Download missing offline cover bundle zips from the store server.
``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.
"""
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
def cover_url(name, kind="Named_Boxarts", system="games"):
"""Public libretro URL of an image for a catalog name."""
return f"{_libretro_base(system)}/{kind}/{urllib.parse.quote(name)}.png"
def cover_path(release_id, system):
"""Local cache path for a release's cover."""
d = config.COVERS_DIR / (system or "unknown")
d.mkdir(parents=True, exist_ok=True)
return d / f"{release_id}.png"
def stable_key(name):
"""Process-independent cache key for releases without a database id."""
import hashlib
return "n-" + hashlib.md5(name.encode("utf-8")).hexdigest()[:12]
def _is_image(data):
if not isinstance(data, (bytes, bytearray)):
return False
if data.startswith(_PNG_MAGIC) or data.startswith(_JPEG_MAGIC):
return True
return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
def file_is_image(path):
try:
with open(path, "rb") as f:
head = f.read(16)
if head.startswith(_PNG_MAGIC) or head.startswith(_JPEG_MAGIC):
return True
return head[:4] == b"RIFF" and head[8:12] == b"WEBP"
except OSError:
return False
def _write_atomic(dest, data):
tmp = dest.with_name(dest.name + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, dest)
def _http_get(url, timeout=10):
try:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return None
return resp.read()
except Exception:
return None
def _clean_title(s):
"""Normalize title punctuation to match libretro filename conventions."""
s = s.replace(":", " - ")
s = s.replace("\u2122", "").replace("\u00ae", "") # ™ and ®
return re.sub(r"\s+", " ", s).strip()
def _candidate_names(name):
"""Yield progressively shorter thumbnail names (exact first)."""
for base in (name, _clean_title(name)):
yield base
s = base
while True:
stripped = re.sub(r"\s*\([^()]*\)\s*$", "", s).strip()
if not stripped or stripped == s:
break
s = stripped
yield s
def _fetch_png(name, kind, timeout, system="games"):
"""Fetch a real image from libretro, following symlink text bodies.
GitHub serves a repo symlink as a tiny text body containing the target
path, so a 200 response is not necessarily an image.
"""
target = name
for _ in range(_MAX_HOPS + 1):
data = _http_get(cover_url(target, kind, system), timeout)
if data is None:
return None
if _is_image(data):
return data
text = data.decode("utf-8", "replace").strip()
if not text or "/" in text or not text.endswith(".png"):
return None
target = text[:-4]
return None
# --------------------------------------------------------------------------- #
# libretro filename index + fuzzy matching
# --------------------------------------------------------------------------- #
def _index_path(system="games"):
if system == "games":
return config.COVERS_DIR / "libretro-index.json"
return config.COVERS_DIR / f"libretro-index-{system}.json"
def _load_index(force=False, system="games"):
st = _index_state.get(system) or {"ts": 0.0, "names": {}, "norm": {}}
_index_state[system] = st
if not force and st["names"] and time.time() - st["ts"] < _INDEX_TTL:
return st["names"]
path = _index_path(system)
if not force and path.exists():
try:
# Load the on-disk index so it doubles as a fallback if the network
# is down, but only return it immediately while it's still fresh.
names = json.loads(path.read_text(encoding="utf-8"))
_set_index(names, system)
if time.time() - path.stat().st_mtime < _INDEX_TTL:
return names
except Exception:
pass
data = _http_get(_libretro_tree_url(system), timeout=30)
if data:
try:
tree = json.loads(data).get("tree", [])
names = {kind: [] for kind in _LIBRETRO_KINDS}
for entry in tree:
p = entry.get("path", "")
for kind in _LIBRETRO_KINDS:
prefix = kind + "/"
if p.startswith(prefix) and p.endswith(".png"):
names[kind].append(p[len(prefix):-4])
_set_index(names, system)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(names), encoding="utf-8")
except OSError:
pass
return names
except Exception:
pass
return st["names"] or {kind: [] for kind in _LIBRETRO_KINDS}
def _set_index(names, system="games"):
"""Store the raw filename index plus a pre-normalized copy for fast lookup."""
st = _index_state.get(system) or {"ts": 0.0, "names": {}, "norm": {}}
_index_state[system] = st
st["names"] = names
st["norm"] = {k: [(_norm(n), n) for n in v] for k, v in names.items()}
st["ts"] = time.time()
def _norm(s):
s = s.lower().replace("&", "and")
return re.sub(r"[^a-z0-9]+", "", s)
def _plain(s):
"""Strip parenthetical tags from a title."""
return re.sub(r"\s*\([^()]*\)", "", s).strip()
def _index_best(name, kind, system="games"):
"""Return the best-matching repo filename for `name`, or None."""
st = _index_state.get(system) or {"ts": 0.0, "names": {}, "norm": {}}
if not st["norm"]:
_load_index(system=system)
st = _index_state.get(system) or st
pairs = st["norm"].get(kind, [])
db = _norm(name)
if not pairs or len(db) < 5:
return None
base = _norm(_plain(name))
best, best_key = None, None
for nn, n in pairs:
if nn == db:
score = 5
elif nn.startswith(db) or db.startswith(nn):
score = 3
elif db in nn or nn in db:
score = 2
else:
continue
# Prefix/substring matches are only trusted when the core titles also
# match; otherwise they produce false positives (e.g. "SSX On Tour"
# matching "SSX - On Tour Portable", or "Spider-Man" matching
# "Spider-Man - Friend or Foe").
if score < 5 and _norm(_plain(n)) != base:
continue
nl = n.lower()
bad = any(t in nl for t in ("(beta)", "(demo)", "(proto)", "(alpha)", "(sample)", "(promo)"))
region = 3
if "(usa" in nl:
region = 0
elif "(europe" in nl:
region = 1
elif "(japan" in nl or "(asia" in nl or "(korea" in nl:
region = 2
key = (-score, int(bad), region, len(n))
if best_key is None or key < best_key:
best, best_key = n, key
return best
def _fetch_libretro(name, timeout, system="games", indexed_only=False):
# 0. Shipped offline bundle first (fast, no network) — PSP only for now.
if system == "games":
data = fetch_libretro_local(name)
if data is not None:
return data
if not indexed_only:
# 1. Cheap: exact + progressively stripped names (no index needed).
for kind in _LIBRETRO_KINDS:
for cand in _candidate_names(name):
data = _fetch_png(cand, kind, timeout, system)
if data is not None:
return data
# 2. Fuzzy/exact via the precomputed index (covers exact names too, so the
# bulk loader skips the wasteful exact pass entirely).
for kind in _LIBRETRO_KINDS:
best = _index_best(name, kind, system)
if best:
data = _fetch_png(best, kind, timeout, system)
if data is not None:
return data
return None
# --------------------------------------------------------------------------- #
# Manual covers (user-provided UMD video / game cover images)
# --------------------------------------------------------------------------- #
def _names_match(a, b):
a, b = _norm(a), _norm(b)
if not a or not b:
return False
if len(a) < 4 or len(b) < 4:
return a == b
return a == b or a.startswith(b) or b.startswith(a)
def _title_variants(name):
base = re.sub(r"\s*\([^()]*\)", "", name).strip()
variants = [name, base]
m = re.match(r"^(.*),\s*(the|a|an)$", base, re.IGNORECASE)
if m:
variants.append(f"{m.group(2)} {m.group(1)}")
return variants
def fetch_manual(name, system):
d = _MANUAL_DIR / (system or "unknown")
if not d.is_dir():
return None
variants = _title_variants(name)
for f in d.iterdir():
if f.suffix.lower() not in (".png", ".jpg", ".jpeg", ".webp"):
continue
if not any(_names_match(f.stem, v) for v in variants):
continue
try:
data = f.read_bytes()
except OSError:
continue
if _is_image(data):
return data
return None
# --------------------------------------------------------------------------- #
# OpenVGDB local bundle (shipped offline covers, see app/openvgdb.py)
# --------------------------------------------------------------------------- #
_ovgdb_names = None # cached set of normalized titles present in the bundle
def _ovgdb_name_list():
global _ovgdb_names
if _ovgdb_names is None:
_ovgdb_names = set()
if _OVGDB_ZIP.exists():
try:
with zipfile.ZipFile(_OVGDB_ZIP) as z:
_ovgdb_names = {n[:-4] for n in z.namelist() if n.endswith(".jpg")}
except Exception: # noqa: BLE001
pass
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):
"""Return a bundled OpenVGDB cover image for `name`, or None."""
names = _ovgdb_name_list()
if not names:
return None
key = _norm(_plain(name))
entry = None
if key in names:
entry = key + ".jpg"
else:
for n in names:
if n.startswith(key) or key.startswith(n):
entry = n + ".jpg"
break
if entry is None:
return None
try:
with zipfile.ZipFile(_OVGDB_ZIP) as z:
data = z.read(entry)
return data if _is_image(data) else None
except Exception: # noqa: BLE001
return None
def fetch_libretro_local(name):
"""Return a bundled libretro box art for `name`, or None."""
names = _libretro_name_list()
if not names:
return None
key = _norm(_plain(name))
entry = None
if key in names:
entry = key + ".png"
else:
for n in names:
if n.startswith(key) or key.startswith(n):
entry = n + ".png"
break
if entry is None:
return None
try:
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
data = z.read(entry)
return data if _is_image(data) else None
except Exception: # noqa: BLE001
return None
def _libretro_name_list():
global _libretro_names
if _libretro_names is None:
_libretro_names = set()
if _LIBRETRO_ZIP.exists():
try:
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
_libretro_names = {n[:-4] for n in z.namelist() if n.endswith(".png")}
except Exception: # noqa: BLE001
pass
return _libretro_names
_libretro_names = None
# --------------------------------------------------------------------------- #
# ScreenScraper (games box art, needs devid/devpassword)
# --------------------------------------------------------------------------- #
def _screenscraper_box_urls(jeu):
medias = jeu.get("medias", [])
if isinstance(medias, dict):
medias = list(medias.values())
urls = []
for m in medias or []:
if not isinstance(m, dict):
continue
if m.get("type") not in ("box-2D", "box-3D"):
continue
url = m.get("url")
if url and url not in urls:
urls.append(url)
return urls
def _fetch_screenscraper(name, timeout, system="games"):
creds = sources.load()
devid = creds.get("screenscraper_devid")
devpassword = creds.get("screenscraper_devpassword")
if not devid or not devpassword:
return None
params = {
"devid": devid,
"devpassword": devpassword,
"softname": creds.get("screenscraper_softname") or "psp-preserve",
"output": "json",
"systemeid": _platform(system)["ss_id"],
"romtype": "rom",
"romnom": name,
}
ssid = creds.get("screenscraper_ssid")
if ssid:
params["ssid"] = ssid
params["sspassword"] = creds.get("screenscraper_sspassword", "")
data = _http_get(_SS_API + "?" + urllib.parse.urlencode(params), timeout=timeout)
if not data:
return None
try:
jeux = json.loads(data).get("response", {}).get("jeu")
except Exception:
return None
if isinstance(jeux, dict):
jeux = [jeux]
for jeu in jeux or []:
if not isinstance(jeu, dict):
continue
for url in _screenscraper_box_urls(jeu):
img = _http_get(url, timeout=timeout)
if _is_image(img):
return img
return None
def _title_score(query, title):
q, t = _norm(query), _norm(title)
if not q or not t:
return 0
if q == t:
return 5
if t.startswith(q) or q.startswith(t):
return 4
if q in t or t in q:
return 3
return 0
# --------------------------------------------------------------------------- #
# Movie posters (for compositing into the UMD template)
# --------------------------------------------------------------------------- #
def movie_search_title(name):
"""'Dark Knight, The (Europe)' -> 'The Dark Knight'; 'Family Guy Vol. 1' -> 'Family Guy'."""
base = re.sub(r"\s*\([^()]*\)", "", name).strip()
base = re.sub(r"\s*vol(?:ume)?\.?\s*\d+\s*$", "", base, flags=re.IGNORECASE).strip()
m = re.match(r"^(.*),\s*(the|a|an)$", base, re.IGNORECASE)
if m:
base = f"{m.group(2)} {m.group(1)}"
return base.strip()
def _wiki_search(title, limit=5):
url = _WIKI_SEARCH + "?" + urllib.parse.urlencode({
"action": "opensearch", "search": title, "limit": limit,
"namespace": 0, "format": "json",
})
data = _http_get(url, timeout=10)
if not data:
return []
try:
arr = json.loads(data)
if isinstance(arr, list) and len(arr) >= 2:
return arr[1]
except Exception:
pass
return []
def _wiki_thumbnail(title):
url = _WIKI_SUMMARY + urllib.parse.quote(title.replace(" ", "_"))
data = _http_get(url, timeout=10)
if not data:
return None
try:
obj = json.loads(data)
thumb = obj.get("thumbnail") or {}
src = thumb.get("source") or (obj.get("originalimage") or {}).get("source")
except Exception:
return None
if not src:
return None
return re.sub(r"/\d+px-", "/600px-", src)
def fetch_poster(title, timeout=10):
"""Return a movie poster image (bytes) from TheMovieDB then Wikipedia."""
if not title or len(title.strip()) < 2:
return None
# 1. TheMovieDB (better quality, instant key)
for r in _tmdb_results(title, timeout)[:1]:
data = _http_get(r["image_url"], timeout)
if _is_image(data):
return data
# 2. Wikipedia fallback (keyless)
bases = [title]
if "(film)" not in title.lower():
bases.append(title + " (film)")
for base in bases:
for page in _wiki_search(base):
src = _wiki_thumbnail(page)
if not src:
continue
data = _http_get(src, timeout)
if _is_image(data):
return data
return None
def save_cover(release_id, system, data):
"""Atomically store composited cover bytes in the cache; return the path."""
dest = cover_path(release_id, system)
_write_atomic(dest, data)
_clear_missing(str(release_id), system)
return dest
# --------------------------------------------------------------------------- #
# Game cover compositing (IGDB key art -> full PSP cover)
# --------------------------------------------------------------------------- #
_game_template_image = None
def _transparent_rect(img):
"""Bounding box of the fully-transparent pixels in `img`."""
minx, miny, maxx, maxy = img.width(), img.height(), -1, -1
for y in range(img.height()):
for x in range(img.width()):
if ((img.pixel(x, y) >> 24) & 0xFF) == 0:
minx, miny = min(minx, x), min(miny, y)
maxx, maxy = max(maxx, x), max(maxy, y)
if maxx < minx or maxy < miny:
return (0, 0, 0, 0)
return (minx, miny, maxx - minx + 1, maxy - miny + 1)
def _game_template():
"""Load the PSP game cover template (top header + transparent window)."""
global _game_template_image
if _game_template_image is None:
try:
from PySide6.QtGui import QImage
except Exception: # noqa: BLE001 - headless API may lack PySide6
return None
img = QImage()
for path in (
config.COVERS_DIR / "UMD_GAME_COVER_TEMPLATE.png",
config.DATA_DIR / "covers" / "UMD_GAME_COVER_TEMPLATE.png",
):
if path.exists():
img.load(str(path))
if not img.isNull():
break
_game_template_image = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
return _game_template_image
def compose_game_cover(key_art_bytes):
"""Paste IGDB's cropped key art into the PSP game cover template window.
Returns composited PNG bytes, or the original bytes when Qt or the template
is unavailable (the headless HTTP API may not have PySide6 installed).
"""
try:
from PySide6.QtCore import QBuffer, QByteArray, Qt
from PySide6.QtGui import QImage, QPainter
except Exception: # noqa: BLE001
return key_art_bytes
template = _game_template()
art = QImage.fromData(key_art_bytes)
if template is None or template.isNull() or art.isNull():
return key_art_bytes
art = art.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent)
x, y, w, h = _transparent_rect(template)
painter = QPainter(out)
if w > 0 and h > 0:
scaled = art.scaled(w, h, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
ox = x + (w - scaled.width()) // 2
oy = y + (h - scaled.height()) // 2
painter.save()
painter.setClipRect(x, y, w, h)
painter.drawImage(ox, oy, scaled)
painter.restore()
painter.drawImage(0, 0, template)
painter.end()
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QBuffer.WriteOnly)
out.save(buf, "PNG")
buf.close()
return bytes(ba)
def make_ps1_assets(cover_bytes):
"""Build ICON0.PNG (144x80) and PIC1.PNG (480x272) from PS1 cover art.
Returns ``(icon0_bytes, pic1_bytes)``, or ``(None, None)`` when the art
can't be decoded (or Qt isn't available). ICON0 is the art letterboxed
inside the icon frame; PIC1 is the art cropped to fill the launch
background.
"""
try:
from PySide6.QtCore import QBuffer, QByteArray, Qt
from PySide6.QtGui import QImage, QPainter
except Exception: # noqa: BLE001 - headless API may lack PySide6
return None, None
art = QImage.fromData(cover_bytes)
if art.isNull():
return None, None
art = art.convertToFormat(QImage.Format_ARGB32)
def _save(img):
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QBuffer.WriteOnly)
img.save(buf, "PNG")
buf.close()
return bytes(ba)
# ICON0: fit the cover inside 144x80 (transparent margins).
icon = QImage(144, 80, QImage.Format_ARGB32)
icon.fill(Qt.transparent)
p = QPainter(icon)
scaled = art.scaled(144, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
p.drawImage((144 - scaled.width()) // 2, (80 - scaled.height()) // 2, scaled)
p.end()
# PIC1: crop the cover to fill 480x272.
pic = QImage(480, 272, QImage.Format_ARGB32)
pic.fill(Qt.transparent)
p = QPainter(pic)
scaled = art.scaled(480, 272, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
p.drawImage((480 - scaled.width()) // 2, (272 - scaled.height()) // 2, scaled)
p.end()
return _save(icon), _save(pic)
def ps1_art(title, release_id, timeout=10):
"""Fetch a PS1 game's cover and return ``(icon0, pic1)`` PNG bytes.
Reuses the local cover cache when available, otherwise searches the PS1
sources (libretro, ScreenScraper, IGDB, Wikipedia). Returns
``(None, None)`` when no art can be produced.
"""
try:
path = fetch_cover(title, release_id, "ps1", timeout=timeout)
except Exception: # noqa: BLE001
return None, None
if path is None:
return None, None
try:
return make_ps1_assets(path.read_bytes())
except OSError:
return None, None
# --------------------------------------------------------------------------- #
# Public entry point
# --------------------------------------------------------------------------- #
def fetch_cover(name, release_id, system, timeout=10, quick=False):
"""Download a cover into the cache; return the cached path or None.
``quick`` limits the game fallback to libretro only (index-driven and fast),
for bulk store loading; details views use the full IGDB/ScreenScraper path.
"""
dest = cover_path(release_id, system)
if dest.exists():
if file_is_image(dest):
return dest
try:
dest.unlink() # drop corrupt/partial cache and refetch
except OSError:
pass
# Reuse a cover cached under the name-derived key (the Store saves covers
# this way for releases without a DB id), so a cover fetched or manually
# chosen in the Store also applies to the same disc once it is in the Library.
alt = cover_path(stable_key(name), system)
if alt != dest and alt.exists() and file_is_image(alt):
try:
_write_atomic(dest, alt.read_bytes())
return dest
except OSError:
pass
key = str(release_id)
# In-memory + persistent negative caches: skip the network for recent misses.
last = _neg_cache.get(key)
if last is not None and time.time() - last < _NEG_TTL:
return None
_load_miss_db()
miss_ts = _miss_db.get((key, system))
if miss_ts is not None and time.time() - miss_ts < _MISS_TTL:
return None
t = min(timeout, 3 if quick else 6)
deadline = time.time() + max(timeout, 6) * (2 if quick else 4)
# 1. User-provided covers always win.
data = fetch_manual(name, system)
if data is not None:
log.info("cover %r <- manual", name)
elif system != "videos":
# 2. Games/PS1: libretro (full box art) first, then the shipped
# OpenVGDB bundle (PSP only, offline), then ScreenScraper (non-quick),
# then Wikipedia (keyless, non-quick), then IGDB last.
data = _fetch_libretro(name, t, system=system, indexed_only=quick)
if data is not None:
log.info("cover %r <- libretro", name)
else:
data = None
if system == "games":
data = fetch_openvgdb_local(name)
if data is not None:
log.info("cover %r <- openvgdb", name)
if data is None and not quick and time.time() < deadline:
data = _fetch_screenscraper(name, t, system=system)
if data is not None:
log.info("cover %r <- screenscraper", name)
if data is None and not quick and time.time() < deadline:
data = _fetch_wikipedia_cover(name, t)
if data is not None:
log.info("cover %r <- wikipedia", name)
if data is None and time.time() < deadline:
data = _fetch_igdb_cover(name, t, system=system)
if data is not None:
if system == "games":
# IGDB only has cropped 3:4 PSP key art; composite it
# into the full PSP cover template (header + artwork).
data = compose_game_cover(data)
log.info("cover %r <- igdb", name)
if data is None:
_neg_cache[key] = time.time()
_mark_missing(key, system)
return None
_neg_cache.pop(key, None)
_clear_missing(key, system)
_write_atomic(dest, data)
return dest
# --------------------------------------------------------------------------- #
# Manual search (pick a cover from a source)
# --------------------------------------------------------------------------- #
def plain_title(name):
"""Strip parenthetical tags, leaving the core title."""
return re.sub(r"\s*\([^()]*\)", "", name).strip()
def download_image(url, timeout=15):
"""Download and validate an image from a URL; return bytes or None."""
data = _http_get(url, timeout)
return data if _is_image(data) else None
def search_covers(query, source="libretro", limit=15, timeout=10):
"""Return [{title, source, thumb_url, image_url}] for a cover search."""
if source == "igdb":
return search_games_igdb(query, limit, timeout)
if source == "wikipedia":
return _search_wikipedia(query, limit, timeout)
if source == "tmdb":
return _search_tmdb(query, limit, timeout)
return _search_libretro(query, limit)
def _tmdb_results(query, timeout):
"""Return [{title, source, thumb_url, image_url}] of TMDB movie posters."""
key = sources.load().get("tmdb_api_key")
if not key:
return []
url = _TMDB_SEARCH + "?" + urllib.parse.urlencode({"api_key": key, "query": query})
data = _http_get(url, timeout=timeout)
if not data:
return []
try:
results = json.loads(data).get("results") or []
except Exception:
return []
out = []
for r in results:
poster = r.get("poster_path")
if not poster:
continue
title = r.get("title") or r.get("name") or ""
year = (r.get("release_date") or "")[:4]
out.append({
"title": f"{title} ({year})" if year else title,
"source": "tmdb",
"thumb_url": _TMDB_IMAGE + "w185" + poster,
"image_url": _TMDB_IMAGE + "w500" + poster,
})
return out
def _search_tmdb(query, limit, timeout):
return _tmdb_results(query, timeout)[:limit]
def _search_wikipedia(query, limit=15, timeout=10):
"""Return [{title, source, thumb_url, image_url}] of images on Wikipedia.
Keyless: searches the File: namespace (real poster/cover image files),
trying ``<query> cover`` and ``<query> poster`` before the raw query so both
games and movies work.
"""
results = []
for q in (f"{query} cover", f"{query} poster", query):
for r in _wiki_file_search(q, limit, timeout):
if r not in results:
results.append(r)
if len(results) >= limit:
return results
return results
def _search_google_images(query, limit=15, timeout=10):
"""Return [{title, source, thumb_url, image_url}] from Google Images via the
Custom Search JSON API. Requires google_api_key + google_cx; keyless -> [].
"""
key = sources.load().get("google_api_key")
cx = sources.load().get("google_cx")
if not key or not cx:
return []
url = _GOOGLE_CSE_API + "?" + urllib.parse.urlencode({
"key": key,
"cx": cx,
"q": query,
"searchType": "image",
"num": min(max(limit, 1), 10),
"imgSize": "xlarge",
"safe": "off",
})
data = _http_get(url, timeout=timeout)
if not data:
return []
try:
items = json.loads(data).get("items") or []
except Exception: # noqa: BLE001
return []
out = []
for it in items:
link = it.get("link") or ""
thumb = ((it.get("image") or {}).get("thumbnailLink")) or link
if not link:
continue
out.append({
"id": None,
"title": it.get("title") or query,
"thumb_url": thumb,
"image_url": link,
"source": "google",
})
return out[:limit]
def _fetch_wikipedia_cover(name, timeout):
"""Return a game cover image from Wikipedia's File: namespace (keyless)."""
base = plain_title(name)
if len(base) < 3:
return None
seen = set()
candidates = []
for q in (f"{base} cover", base):
for r in _wiki_file_search(q, limit=6, timeout=timeout):
url = r.get("image_url") or r.get("thumb_url")
if url and url not in seen:
seen.add(url)
candidates.append(r)
def rank(r):
t = (r.get("title") or "").lower()
if "cover" in t or "box" in t:
return 0
if "screenshot" in t or "logo" in t or "icon" in t:
return 2
return 1
for r in sorted(candidates, key=rank):
url = r.get("image_url") or r.get("thumb_url")
data = _http_get(url, timeout=timeout)
if _is_image(data):
return data
return None
def _wiki_file_search(query, limit, timeout):
url = _WIKI_SEARCH + "?" + urllib.parse.urlencode({
"action": "query", "list": "search", "srsearch": query,
"srnamespace": "6", "srlimit": str(max(limit, 1)), "format": "json",
})
data = _http_get(url, timeout)
if not data:
return []
try:
hits = json.loads(data).get("query", {}).get("search", [])
except Exception:
return []
titles = [h["title"] for h in hits if h.get("title", "").startswith("File:")]
if not titles:
return []
info_url = _WIKI_SEARCH + "?" + urllib.parse.urlencode({
"action": "query", "titles": "|".join(titles[:limit]),
"prop": "imageinfo", "iiprop": "url", "iiurlwidth": "400",
"format": "json",
})
data = _http_get(info_url, timeout)
if not data:
return []
try:
pages = json.loads(data).get("query", {}).get("pages", {})
except Exception:
return []
out = []
for page in pages.values():
info = (page.get("imageinfo") or [{}])[0]
full = info.get("url")
if not full:
continue
title = page.get("title", "")
if title.startswith("File:"):
title = title[5:]
if title.lower().endswith((".pdf", ".djvu", ".svg")):
continue
out.append({
"title": title,
"source": "wikipedia",
"thumb_url": info.get("thumburl") or full,
"image_url": full,
})
return out
def _search_libretro(query, limit, system="games"):
names = _load_index(system=system).get("Named_Boxarts", [])
db = _norm(query)
results = []
if not db:
return results
scored = []
for n in names:
nn = _norm(n)
if not nn:
continue
if nn == db:
score = 5
elif nn.startswith(db):
score = 4
elif db in nn:
score = 3
elif nn in db:
score = 2
else:
continue
scored.append((score, n))
scored.sort(key=lambda t: (-t[0], t[1]))
for score, n in scored[:limit]:
results.append({
"title": n,
"source": "libretro",
"thumb_url": cover_url(n, system=system),
"image_url": cover_url(n, system=system),
})
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)
# --------------------------------------------------------------------------- #
_META_TTL = 86400.0
def get_game_metadata(title, timeout=10, system="games"):
"""Return game metadata from IGDB (system picks the platform)."""
return get_game_metadata_igdb(title, timeout, system)
# --------------------------------------------------------------------------- #
# IGDB (games box art + metadata; generous free tier, key via Twitch dev app)
# --------------------------------------------------------------------------- #
def _igdb_creds():
return sources.load().get("igdb_client_id"), sources.load().get("igdb_client_secret")
def _igdb_token(timeout=10):
st = _igdb_token_state
if st["token"] and time.time() < st["expires"] - 60:
return st["token"]
cid, secret = _igdb_creds()
if not cid or not secret:
return ""
body = urllib.parse.urlencode({
"client_id": cid, "client_secret": secret, "grant_type": "client_credentials",
}).encode("utf-8")
req = urllib.request.Request(_IGDB_TOKEN_URL, data=body, headers={"User-Agent": _UA["User-Agent"]})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
obj = json.loads(resp.read())
except Exception:
return ""
token = obj.get("access_token") or ""
expires_in = int(obj.get("expires_in") or 0)
st["token"] = token
st["expires"] = time.time() + max(expires_in - 60, 60)
return token
def _igdb_throttle():
global _igdb_last
with _igdb_lock:
wait = 0.26 - (time.time() - _igdb_last)
if wait > 0:
time.sleep(wait)
_igdb_last = time.time()
def _igdb_post(query, timeout=10):
_igdb_throttle()
token = _igdb_token(timeout)
if not token:
return None
cid, _ = _igdb_creds()
req = urllib.request.Request(
_IGDB_API,
data=query.encode("utf-8"),
headers={
"Client-ID": cid,
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": _UA["User-Agent"],
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return None
return json.loads(resp.read())
except Exception:
return None
def _igdb_image_url(image_id, size="cover_big"):
if not image_id:
return ""
if size == "cover_small":
return f"{_IGDB_IMAGE}t_cover_small/{image_id}.jpg"
# IGDB only serves its fixed named transforms (the raw upload path 404s and
# custom Cloudinary params are disabled). t_cover_big_2x is the highest-res
# cover size (528x704). Note: IGDB stores PSP box art as 3:4, so these will
# never match libretro's tall scans — that's IGDB's data, not a crop.
return f"{_IGDB_IMAGE}t_cover_big_2x/{image_id}.jpg"
def _igdb_date(ts):
if not ts:
return ""
try:
import datetime
return datetime.datetime.utcfromtimestamp(int(ts)).strftime("%Y-%m-%d")
except (TypeError, ValueError, OSError):
return ""
def _igdb_game_meta(g):
genres = [x.get("name") for x in (g.get("genres") or []) if isinstance(x, dict) and x.get("name")]
devs, pubs = [], []
for ic in g.get("involved_companies") or []:
if not isinstance(ic, dict):
continue
company = ic.get("company")
company = company.get("name") if isinstance(company, dict) else None
if not company:
continue
if ic.get("developer"):
devs.append(company)
if ic.get("publisher"):
pubs.append(company)
videos = g.get("videos") or []
yt = ""
if videos and isinstance(videos[0], dict) and videos[0].get("video_id"):
yt = "https://www.youtube.com/watch?v=" + videos[0]["video_id"]
rating = g.get("rating")
try:
rating = round(float(rating) / 10.0, 1) if rating else None
except (TypeError, ValueError):
rating = None
return {
"title": g.get("name") or "",
"release_date": _igdb_date(g.get("first_release_date")),
"overview": (g.get("summary") or "").strip(),
"developers": list(dict.fromkeys(devs)),
"publishers": list(dict.fromkeys(pubs)),
"genres": list(dict.fromkeys(genres)),
"players": None,
"rating": rating,
"youtube": yt,
}
def _igdb_search(query, limit=20, timeout=10, system="games"):
cid, _ = _igdb_creds()
if not cid:
return []
term = plain_title(query) or query
platform_id = _platform(system)["igdb_id"]
q = (
f'search "{term}"; '
"fields name, cover.image_id, first_release_date, summary, genres.name, "
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
"rating, videos.video_id; "
f"where platforms = ({platform_id}); "
f"limit {max(limit, 1)};"
)
return _igdb_post(q, timeout) or []
def _fetch_igdb_cover(name, timeout, system="games"):
cid, _ = _igdb_creds()
if not cid:
return None
term = plain_title(name) or name
platform_id = _platform(system)["igdb_id"]
q = f'search "{term}"; fields cover.image_id; where platforms = ({platform_id}); limit 10;'
games = _igdb_post(q, timeout) or []
tried = set()
for g in games:
img_id = (g.get("cover") or {}).get("image_id")
if not img_id or img_id in tried:
continue
tried.add(img_id)
data = _http_get(_igdb_image_url(img_id, "cover_big"), timeout)
if _is_image(data):
return data
return None
def search_games_igdb(query, limit=20, timeout=10, system="games"):
out = []
for g in _igdb_search(query, limit, timeout, system):
cover = (g.get("cover") or {}).get("image_id") or ""
out.append({
"id": g.get("id"),
"source": "igdb",
"title": g.get("name") or "",
"release_date": _igdb_date(g.get("first_release_date")),
"overview": (g.get("summary") or "").strip(),
"thumb_url": _igdb_image_url(cover, "cover_small"),
"image_url": _igdb_image_url(cover, "cover_big"),
})
out.sort(key=lambda r: -_title_score(query, r["title"]))
return out[:limit]
def get_game_metadata_igdb(title, timeout=10, system="games"):
games = _igdb_search(title, limit=10, timeout=timeout, system=system)
if not games:
return None
games.sort(key=lambda g: -_title_score(title, g.get("name") or ""))
return _igdb_game_meta(games[0])
def game_metadata_by_id_igdb(game_id, timeout=10):
cid, _ = _igdb_creds()
if not cid:
return None
q = (
"fields name, cover.image_id, first_release_date, summary, genres.name, "
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
"rating, videos.video_id; "
f"where id = {int(game_id)}; limit 1;"
)
games = _igdb_post(q, timeout) or []
return _igdb_game_meta(games[0]) if games else None
# --------------------------------------------------------------------------- #
# Movie metadata (details sidebar for UMD videos)
# --------------------------------------------------------------------------- #
_tmdb_genres = {"ts": 0.0, "map": {}}
def _tmdb_genre_map(timeout):
if _tmdb_genres["map"] and time.time() - _tmdb_genres["ts"] < _META_TTL:
return _tmdb_genres["map"]
key = sources.load().get("tmdb_api_key")
if not key:
return {}
url = "https://api.themoviedb.org/3/genre/movie/list?" + urllib.parse.urlencode({"api_key": key})
data = _http_get(url, timeout=timeout)
if not data:
return _tmdb_genres["map"]
try:
genres = json.loads(data).get("genres") or []
except Exception:
return _tmdb_genres["map"]
result = {g["id"]: g["name"] for g in genres if isinstance(g, dict)}
_tmdb_genres["map"] = result
_tmdb_genres["ts"] = time.time()
return result
def _tmdb_trailer(movie_id, key, timeout):
url = "https://api.themoviedb.org/3/movie/{}/videos?".format(movie_id) + urllib.parse.urlencode({"api_key": key})
data = _http_get(url, timeout=timeout)
if not data:
return ""
try:
results = json.loads(data).get("results") or []
except Exception:
return ""
for v in results:
if v.get("site") == "YouTube" and v.get("type") == "Trailer" and v.get("key"):
return "https://www.youtube.com/watch?v=" + v["key"]
for v in results:
if v.get("site") == "YouTube" and v.get("key"):
return "https://www.youtube.com/watch?v=" + v["key"]
return ""
def _wiki_extract(title):
url = _WIKI_SUMMARY + urllib.parse.quote(title.replace(" ", "_"))
data = _http_get(url, timeout=10)
if not data:
return None
try:
return (json.loads(data).get("extract") or "").strip()
except Exception:
return None
def get_movie_metadata(title, timeout=10):
"""Return movie metadata (release date, synopsis, trailer) for a UMD video."""
if not title:
return None
key = sources.load().get("tmdb_api_key")
if key:
url = _TMDB_SEARCH + "?" + urllib.parse.urlencode({"api_key": key, "query": title})
data = _http_get(url, timeout=timeout)
if data:
try:
results = json.loads(data).get("results") or []
except Exception:
results = []
if results:
m = results[0]
genres = _tmdb_genre_map(timeout)
vote = m.get("vote_average")
return {
"title": m.get("title") or m.get("name") or "",
"release_date": m.get("release_date") or "",
"overview": (m.get("overview") or "").strip(),
"genres": [genres.get(gid, "") for gid in (m.get("genre_ids") or []) if genres.get(gid)],
"rating": f"{vote}/10" if isinstance(vote, (int, float)) else None,
"youtube": _tmdb_trailer(m.get("id"), key, timeout),
}
# Wikipedia fallback (keyless): description only
for page in _wiki_search(title):
extract = _wiki_extract(page)
if extract:
return {
"title": page,
"release_date": "",
"overview": extract,
"genres": [],
"rating": None,
"youtube": "",
}
return None
# --------------------------------------------------------------------------- #
# Manual linking (pick the correct movie/game when the auto-match is wrong)
# --------------------------------------------------------------------------- #
def search_movies(query, limit=20, timeout=10):
"""Return movie candidates for a UMD video: TMDB first, then Wikipedia
images and Google Images (both keyless-optional). A Wikipedia page-title
fallback is added only when nothing else matched (metadata-only linking).
"""
out = []
seen = set()
def add(r):
k = (r.get("source"), _norm(r.get("title") or ""))
if k[1] and k not in seen:
seen.add(k)
out.append(r)
key = sources.load().get("tmdb_api_key")
if key:
url = _TMDB_SEARCH + "?" + urllib.parse.urlencode({"api_key": key, "query": query})
data = _http_get(url, timeout=timeout)
if data:
try:
results = json.loads(data).get("results") or []
except Exception:
results = []
# Cap TMDB results so Wikipedia/Google image picks still fit.
for r in results[: max(1, min(limit, 6))]:
poster = r.get("poster_path")
add({
"id": r.get("id"),
"title": r.get("title") or r.get("name") or "",
"year": (r.get("release_date") or "")[:4],
"overview": (r.get("overview") or "").strip(),
"thumb_url": (_TMDB_IMAGE + "w185" + poster) if poster else "",
"image_url": (_TMDB_IMAGE + "w500" + poster) if poster else "",
"source": "tmdb",
})
# Wikipedia image search (keyless): real poster/cover files.
for r in _search_wikipedia(query, min(limit, 10), timeout):
r["id"] = None
r["movie_title"] = query
add(r)
# Google Images (Custom Search JSON API, when a key + cx are configured).
for r in _search_google_images(query, min(limit, 10), timeout):
r["movie_title"] = query
add(r)
# Last resort: Wikipedia page titles (metadata-only, no image).
if not out:
for page in _wiki_search(query, limit):
out.append({
"id": None,
"title": page,
"year": "",
"overview": "",
"thumb_url": "",
"image_url": "",
"movie_title": page,
"source": "wikipedia",
})
return out[:limit]
def movie_metadata_by_id(movie_id, timeout=10):
"""Return full metadata for a specific TMDB movie id (manual linking)."""
key = sources.load().get("tmdb_api_key")
if not key:
return None
url = "https://api.themoviedb.org/3/movie/{}?".format(movie_id) + urllib.parse.urlencode({"api_key": key})
data = _http_get(url, timeout=timeout)
if not data:
return None
try:
m = json.loads(data)
except Exception:
return None
vote = m.get("vote_average")
return {
"title": m.get("title") or m.get("name") or "",
"release_date": m.get("release_date") or "",
"overview": (m.get("overview") or "").strip(),
"genres": [g.get("name") for g in (m.get("genres") or []) if isinstance(g, dict)],
"rating": f"{vote}/10" if isinstance(vote, (int, float)) else None,
"youtube": _tmdb_trailer(m.get("id"), key, timeout),
}
def movie_metadata_from_wikipedia(page_title, timeout=10):
"""Return metadata for a Wikipedia page chosen by the user (keyless)."""
extract = _wiki_extract(page_title)
if not extract:
return None
return {
"title": page_title,
"release_date": "",
"overview": extract,
"genres": [],
"rating": None,
"youtube": "",
}
def search_metadata_candidates(system, query, limit=20, timeout=10):
"""Search candidates the user can link an item to.
Games/PS1: IGDB (rich metadata) first, then libretro box art (and the
PSP-only OpenVGDB bundle) so covers can be scraped even without an IGDB key.
"""
if system == "videos":
return search_movies(query, limit, timeout)
results = []
seen = set()
def add(r):
key = (r.get("source"), _norm(r.get("title") or ""))
if key[1] and key not in seen:
seen.add(key)
results.append(r)
# Cap IGDB so libretro/OpenVGDB cover picks still fit in the list.
for r in search_games_igdb(query, max(1, min(limit, 6)), timeout, system):
add(r)
for r in _search_libretro(query, limit, system):
r.setdefault("id", None)
add(r)
if system == "games":
for r in _search_openvgdb(query, limit):
add(r)
return results[:limit]
def _empty_meta(title=""):
return {
"title": title,
"release_date": "",
"overview": "",
"genres": [],
"rating": None,
"youtube": "",
}
def link_metadata(system, candidate, timeout=10):
"""Fetch full metadata for a search candidate the user picked."""
src = candidate.get("source")
if src == "wikipedia":
title = candidate.get("movie_title") or candidate.get("title") or ""
meta = movie_metadata_from_wikipedia(title, timeout) if title else None
if meta:
return meta
if system == "videos":
return get_movie_metadata(title, timeout) or _empty_meta(title)
return _empty_meta(title)
if src in ("libretro", "openvgdb"):
# 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, system)
if meta:
return meta
return _empty_meta(title)
if system == "videos":
if src == "tmdb" and candidate.get("id") is not None:
return movie_metadata_by_id(candidate["id"], timeout)
title = candidate.get("movie_title") or candidate.get("title") or ""
return get_movie_metadata(title, timeout) or _empty_meta(title)
if src == "igdb" and candidate.get("id") is not None:
return game_metadata_by_id_igdb(candidate["id"], timeout)
return _empty_meta(candidate.get("title") or "")