Replaces the letterboxed 144x80 icon + cropped PIC1 with official-looking PS1 Classics art: - ICON0 (80x80): cover art composited into the bundled case frame (PS logo + 'PlayStation.' wordmark) from data/ps1_templates/ICON0.PNG. - PIC0 (310x180): a PSN-style info board drawn in code — title, IGDB description, ESRB badge (from IGDB age_ratings) and PS3/PSP badges. - PIC1 (480x272): the bundled launch background template. The converter now embeds PIC0, the PS1 tab previews all three images, and ps1_art pulls IGDB description/ESRB as best effort. Templates are git-ignored (user-supplied, like base.PBP) with a drawn-art fallback when missing.
1933 lines
65 KiB
Python
1933 lines
65 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)
|
|
|
|
|
|
_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates"
|
|
|
|
|
|
def _ps1_template_bytes(name):
|
|
"""Read a bundled PS1 art template (``ICON0.PNG`` / ``PIC1.PNG``)."""
|
|
try:
|
|
p = _PS1_TEMPLATE_DIR / name
|
|
if p.is_file():
|
|
return p.read_bytes()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _slot_rect(img):
|
|
"""Find the fully-transparent cover slot in an icon template image.
|
|
|
|
Returns ``(x, y, w, h)`` or ``None`` when the image has no transparent
|
|
region (e.g. a flattened template)."""
|
|
if img.isNull():
|
|
return None
|
|
w, h = img.width(), img.height()
|
|
min_x = min_y = 1 << 30
|
|
max_x = max_y = -1
|
|
for y in range(h):
|
|
for x in range(w):
|
|
if img.pixelColor(x, y).alpha() < 10:
|
|
min_x, max_x = min(min_x, x), max(max_x, x)
|
|
min_y, max_y = min(min_y, y), max(max_y, y)
|
|
if max_x < min_x or max_y < min_y:
|
|
return None
|
|
return min_x, min_y, max_x - min_x + 1, max_y - min_y + 1
|
|
|
|
|
|
def make_ps1_assets(cover_bytes, title="", description="", esrb=None):
|
|
"""Build official-style PS1 Classics art from the cover: (icon0, pic0, pic1).
|
|
|
|
- ``ICON0`` (80x80): the cover composited into the bundled case template
|
|
(PS button logo, ``PlayStation.`` wordmark, gradient frame).
|
|
- ``PIC0`` (310x180): the PSN-style info board — title, description,
|
|
ESRB rating badge and PS3/PSP platform badges.
|
|
- ``PIC1`` (480x272): the bundled launch background (gradient + curve).
|
|
|
|
Returns ``(None, None, None)`` when the art can't be decoded or Qt is
|
|
unavailable.
|
|
"""
|
|
try:
|
|
from PySide6.QtCore import QBuffer, QByteArray, QPointF, QRectF, Qt
|
|
from PySide6.QtGui import (QColor, QFont, QFontMetrics, QImage,
|
|
QLinearGradient, QPainter, QPainterPath,
|
|
QPen, QPolygonF)
|
|
except Exception: # noqa: BLE001 - headless API may lack PySide6
|
|
return None, None, None
|
|
|
|
art = QImage.fromData(cover_bytes)
|
|
if art.isNull():
|
|
return None, 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: cover art inside the case template ---------------------------
|
|
icon = QImage(80, 80, QImage.Format_ARGB32)
|
|
icon.fill(QColor(0, 0, 0))
|
|
p = QPainter(icon)
|
|
p.setRenderHint(QPainter.SmoothPixmapTransform, True)
|
|
tmpl = QImage.fromData(_ps1_template_bytes("ICON0.PNG") or b"")
|
|
if not tmpl.isNull():
|
|
tmpl = tmpl.convertToFormat(QImage.Format_ARGB32)
|
|
slot = _slot_rect(tmpl) or (0, 0, 80, 80)
|
|
sx, sy, sw, sh = slot
|
|
cover = art.scaled(sw, sh, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
|
|
p.drawImage(sx + (sw - cover.width()) // 2, sy + (sh - cover.height()) // 2, cover)
|
|
p.drawImage(0, 0, tmpl)
|
|
else:
|
|
cover = art.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
p.drawImage((80 - cover.width()) // 2, (80 - cover.height()) // 2, cover)
|
|
p.end()
|
|
icon0 = _save(icon)
|
|
|
|
# --- PIC0: PSN-style info board -----------------------------------------
|
|
pic0 = _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath,
|
|
QColor, QFont, QFontMetrics, QLinearGradient, QPen,
|
|
QPolygonF, QPointF, QRectF, Qt, _save)
|
|
|
|
# --- PIC1: bundled launch background ------------------------------------
|
|
pic1 = _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save)
|
|
|
|
return icon0, pic0, pic1
|
|
|
|
|
|
def _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save):
|
|
pic = QImage(480, 272, QImage.Format_ARGB32)
|
|
raw = _ps1_template_bytes("PIC1.PNG")
|
|
tmpl = QImage.fromData(raw) if raw else QImage()
|
|
if not tmpl.isNull():
|
|
return _save(tmpl.convertToFormat(QImage.Format_ARGB32))
|
|
# Fallback: a light lavender gradient with a subtle curve (same vibe as
|
|
# the official background) for when the template image is missing.
|
|
grad = QLinearGradient(0, 0, 0, 272)
|
|
grad.setColorAt(0.0, QColor(235, 232, 238))
|
|
grad.setColorAt(1.0, QColor(205, 200, 215))
|
|
p = QPainter(pic)
|
|
p.fillRect(0, 0, 480, 272, grad)
|
|
p.setPen(QPen(QColor(170, 160, 185, 120), 3))
|
|
path = QPainterPath()
|
|
path.moveTo(-40, 300)
|
|
path.quadTo(240, 60, 520, 300)
|
|
p.drawPath(path)
|
|
p.end()
|
|
return _save(pic)
|
|
|
|
|
|
def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
|
|
QFont, QFontMetrics, QLinearGradient, QPen, QPolygonF, QPointF,
|
|
QRectF, Qt, _save):
|
|
W, H = 310, 180
|
|
img = QImage(W, H, QImage.Format_ARGB32)
|
|
|
|
# Dark gradient backdrop.
|
|
grad = QLinearGradient(0, 0, 0, H)
|
|
grad.setColorAt(0.0, QColor(38, 38, 40))
|
|
grad.setColorAt(1.0, QColor(12, 12, 14))
|
|
|
|
p = QPainter(img)
|
|
p.setRenderHint(QPainter.Antialiasing, True)
|
|
p.setRenderHint(QPainter.TextAntialiasing, True)
|
|
p.fillRect(0, 0, W, H, grad)
|
|
|
|
# Rounded border.
|
|
p.setPen(QPen(QColor(110, 110, 115), 1))
|
|
p.setBrush(Qt.NoBrush)
|
|
p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9)
|
|
|
|
# PlayStation button glyphs, top-right.
|
|
_draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF,
|
|
QPolygonF, QPen, Qt)
|
|
|
|
# Title (bold, upper-left).
|
|
f_title = QFont("DejaVu Sans")
|
|
f_title.setPixelSize(19)
|
|
f_title.setBold(True)
|
|
p.setFont(f_title)
|
|
p.setPen(QColor(255, 255, 255))
|
|
p.drawText(QRectF(18, 16, W - 64, 28), Qt.AlignLeft | Qt.AlignVCenter,
|
|
title or "")
|
|
|
|
# Description (wrapped, below title).
|
|
if description:
|
|
f_desc = QFont("DejaVu Sans")
|
|
f_desc.setPixelSize(12)
|
|
p.setFont(f_desc)
|
|
p.setPen(QColor(215, 215, 215))
|
|
text = description.strip()
|
|
fm = QFontMetrics(f_desc)
|
|
# Rough 3-line budget (~40 chars/line at 12px across 272px).
|
|
if fm.horizontalAdvance(text) > 3 * (W - 36):
|
|
text = text[:130].rstrip() + "\u2026"
|
|
p.drawText(QRectF(18, 46, W - 36, 74),
|
|
Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, text)
|
|
|
|
# ESRB badge (bottom-left) + caption.
|
|
if esrb:
|
|
badge = esrb.strip()
|
|
if badge:
|
|
_draw_esrb(p, badge, 18, 140, QRectF, QColor, QFont, QFontMetrics, QPen, Qt)
|
|
|
|
# Platform badges (bottom-right).
|
|
_draw_platform_badge(p, "PS3", W - 14 - 64, 146, QColor(94, 146, 210),
|
|
QRectF, QColor, QFont, QFontMetrics, Qt)
|
|
_draw_platform_badge(p, "PSP", W - 14 - 30, 146, QColor(212, 175, 55),
|
|
QRectF, QColor, QFont, QFontMetrics, Qt)
|
|
|
|
p.end()
|
|
return _save(img)
|
|
|
|
|
|
def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt):
|
|
"""Draw the four PlayStation face-button glyphs in a 2x2 grid."""
|
|
cell = size / 2.0
|
|
pen = QPen(color, max(1.0, size / 14.0))
|
|
pen.setCapStyle(Qt.RoundCap)
|
|
pen.setJoinStyle(Qt.RoundJoin)
|
|
p.setPen(pen)
|
|
p.setBrush(Qt.NoBrush)
|
|
# Triangle (top-left).
|
|
tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)])
|
|
p.drawPolygon(tri)
|
|
# Circle (top-right).
|
|
p.drawEllipse(QRectF(x + cell, y, cell, cell))
|
|
# Cross (bottom-left).
|
|
p.drawLine(QPointF(x + 1, y + cell + 1), QPointF(x + cell - 1, y + size - 1))
|
|
p.drawLine(QPointF(x + cell - 1, y + cell + 1), QPointF(x + 1, y + size - 1))
|
|
# Square (bottom-right).
|
|
p.drawRect(QRectF(x + cell, y + cell, cell, cell))
|
|
|
|
|
|
def _draw_esrb(p, badge, x, y, QRectF, QColor, QFont, QFontMetrics, QPen, Qt):
|
|
f = QFont("DejaVu Sans")
|
|
f.setPixelSize(9)
|
|
f.setBold(True)
|
|
fm = QFontMetrics(f)
|
|
text = badge if badge != "EVERYONE" else "EVERYONE"
|
|
# Shrink until it fits the box (the official badge shows the word).
|
|
size = 9
|
|
while size > 5 and fm.horizontalAdvance(text) > 46:
|
|
size -= 1
|
|
f.setPixelSize(size)
|
|
fm = QFontMetrics(f)
|
|
rect = QRectF(x, y, 50, 24)
|
|
p.setPen(QPen(QColor(20, 20, 20), 1))
|
|
p.setBrush(QColor(255, 255, 255))
|
|
p.drawRoundedRect(rect, 3, 3)
|
|
p.setPen(QColor(20, 20, 20))
|
|
p.setFont(f)
|
|
p.drawText(rect, Qt.AlignCenter, text)
|
|
# Caption.
|
|
fc = QFont("DejaVu Sans")
|
|
fc.setPixelSize(7)
|
|
p.setFont(fc)
|
|
p.setPen(QColor(150, 150, 155))
|
|
p.drawText(QRectF(x + 56, y + 6, 120, 14),
|
|
Qt.AlignLeft | Qt.AlignVCenter, "ESRB CONTENT RATING")
|
|
|
|
|
|
def _draw_platform_badge(p, label, x, y, color, QRectF, QColor, QFont,
|
|
QFontMetrics, Qt):
|
|
f = QFont("DejaVu Sans")
|
|
f.setPixelSize(10)
|
|
f.setBold(True)
|
|
rect = QRectF(x, y, 30, 16)
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(QColor(26, 26, 28))
|
|
p.drawRoundedRect(rect, 4, 4)
|
|
p.setPen(color)
|
|
p.setFont(f)
|
|
p.drawText(rect, Qt.AlignCenter, label)
|
|
|
|
|
|
def ps1_art(title, release_id, timeout=10):
|
|
"""Fetch a PS1 game's cover and build ``(icon0, pic0, pic1)`` PNG bytes.
|
|
|
|
Reuses the local cover cache when available, otherwise searches the PS1
|
|
sources (libretro, ScreenScraper, IGDB, Wikipedia). The description and
|
|
ESRB rating for the PIC0 info board come from IGDB (best effort).
|
|
Returns ``(None, None, None)`` when no art can be produced.
|
|
"""
|
|
key = release_id or stable_key(title)
|
|
try:
|
|
path = fetch_cover(title, key, "ps1", timeout=timeout)
|
|
except Exception: # noqa: BLE001
|
|
return None, None, None
|
|
if path is None:
|
|
return None, None, None
|
|
|
|
description = ""
|
|
esrb = None
|
|
try:
|
|
meta = get_game_metadata_igdb(title, timeout=timeout, system="ps1")
|
|
if meta:
|
|
description = meta.get("overview") or ""
|
|
esrb = meta.get("esrb")
|
|
except Exception: # noqa: BLE001 - metadata is best effort
|
|
pass
|
|
|
|
try:
|
|
return make_ps1_assets(path.read_bytes(), title=title,
|
|
description=description, esrb=esrb)
|
|
except OSError:
|
|
return None, 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 ""
|
|
|
|
|
|
# IGDB ``age_ratings.rating`` values for ESRB (category 1). 6=RP, 7=EC, 8=E,
|
|
# 9=E10+, 10=T, 11=M, 12=AO. The badge uses the short letter form.
|
|
_ESRB_BADGE = {6: "RP", 7: "EC", 8: "EVERYONE", 9: "EVERYONE 10+",
|
|
10: "TEEN", 11: "MATURE", 12: "ADULTS ONLY"}
|
|
|
|
|
|
def _igdb_esrb(g):
|
|
for ar in g.get("age_ratings") or []:
|
|
if not isinstance(ar, dict):
|
|
continue
|
|
if ar.get("category") == 1: # ESRB
|
|
return _ESRB_BADGE.get(ar.get("rating"))
|
|
return None
|
|
|
|
|
|
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,
|
|
"esrb": _igdb_esrb(g),
|
|
"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, age_ratings.category, age_ratings.rating; "
|
|
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, age_ratings.category, age_ratings.rating; "
|
|
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 "")
|
|
|