From 55182a4da7eaf70afb2e32b8cb9a6499ad8d221a Mon Sep 17 00:00:00 2001 From: Christopher Koch Date: Tue, 4 Aug 2026 11:41:02 -0400 Subject: [PATCH] Add cover art fetcher; letterbox covers instead of cropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/cover-art/fetch_art.py matches each game against libretro-thumbnails by title + system and attaches the result through the app's own POST /api/images, so fetched art goes through the same validation and WebP re-encoding as a manual upload. Standard library only. Matching bridges a personal catalogue and a ROM-naming one: * accents stripped, so "Pokemon Yellow" reaches "Pokémon" * roman numerals folded to digits, so the SNES "Final Fantasy 2" lands on "Final Fantasy II" and the PS1 "Final Fantasy V" on its own entry * trailing articles unwound ("Sims 2, The" -> "The Sims 2") * subtitle containment in both directions, since our rows sometimes omit what the catalogue carries ("Wave Race 64" vs "... - Kawasaki Jet Ski") and sometimes carry what it omits ("Donkey Kong Country 2: Diddy's Kong Quest" vs the GBA set's "Donkey Kong Country 2") * a sequel guard, so containment cannot collapse "Donkey Kong Country 2" onto "Donkey Kong Country" * fuzzy enough to absorb typos: "Brett Hull Hocky 95" finds "Hockey 95" 93 of 105 games now have art. The remainder: 10 Xbox 360 titles, which libretro has no thumbnail set for, and two rows whose platform looks wrong in the source data (a Game Boy "Donkey Kong Country 2", which was never released on that system, and a DS "Donkey Kong Country Returns", which was Wii and later 3DS). Real art also invalidated a layout assumption: the grid used object-fit: cover, which was fine for uniform placeholders but crops actual boxes, whose aspect ratios run from near-square SNES to tall N64. Switched the grid and the editor preview to object-fit: contain so the whole cover is visible. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + README.md | 32 ++ .../src/app/features/game-edit/game-edit.scss | 3 +- .../src/app/features/game-grid/game-grid.scss | 6 +- .../__pycache__/fetch_art.cpython-314.pyc | Bin 0 -> 22485 bytes tools/cover-art/fetch_art.py | 397 ++++++++++++++++++ 6 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 tools/cover-art/__pycache__/fetch_art.cpython-314.pyc create mode 100644 tools/cover-art/fetch_art.py diff --git a/.gitignore b/.gitignore index 92459cc..7edce68 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ testem.log .DS_Store Thumbs.db *.swp + +# Cached libretro directory listings (regenerated on demand) +tools/cover-art/.cache/ diff --git a/README.md b/README.md index 84b344a..a694b2e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,38 @@ cd frontend && npm test # vitest cd backend && dotnet build # 0 warnings expected ``` +### Cover art + +`tools/cover-art/fetch_art.py` fills in box art from +[libretro-thumbnails](https://thumbnails.libretro.com), matching on title + +system and pushing each image through the app's own `POST /api/images`, so it +gets the same validation and WebP re-encoding as a manual upload. Standard +library only — no virtualenv needed. + +```bash +cd tools/cover-art +python3 fetch_art.py --password '...' --dry-run # report matches, change nothing +python3 fetch_art.py --password '...' # download and attach +python3 fetch_art.py --password '...' --overwrite # also replace existing art +``` + +Always dry-run first; it prints every match with a similarity score and flags +anything below 0.95 for eyeballing. + +Matching handles the gaps between a personal catalogue and a ROM-naming one: +accents (`Pokemon` → `Pokémon`), roman numerals (our SNES `Final Fantasy 2` is +the catalogue's `Final Fantasy II`), trailing articles (`Sims 2, The`), missing +subtitles in either direction, and outright typos — `Brett Hull Hocky 95` finds +`Brett Hull Hockey 95`. A sequel guard stops `Donkey Kong Country 2` from +silently taking `Donkey Kong Country`'s box. + +**Xbox 360 is not covered** — libretro has no thumbnail set for it, so those 10 +titles are reported as unsupported. They need a source such as IGDB, which +requires a free Twitch developer client ID and secret. + +Art is publisher copyright. Fetching it for a private collection is ordinary +practice for library software; redistributing it is a different question. + ### Database changes ```bash diff --git a/frontend/src/app/features/game-edit/game-edit.scss b/frontend/src/app/features/game-edit/game-edit.scss index 4b2646a..20c650e 100644 --- a/frontend/src/app/features/game-edit/game-edit.scss +++ b/frontend/src/app/features/game-edit/game-edit.scss @@ -34,7 +34,8 @@ img { width: 100%; height: 100%; - object-fit: cover; + /* Matches the grid: show the whole cover rather than cropping it. */ + object-fit: contain; display: block; } } diff --git a/frontend/src/app/features/game-grid/game-grid.scss b/frontend/src/app/features/game-grid/game-grid.scss index aac1ada..6053b14 100644 --- a/frontend/src/app/features/game-grid/game-grid.scss +++ b/frontend/src/app/features/game-grid/game-grid.scss @@ -72,7 +72,11 @@ img { width: 100%; height: 100%; - object-fit: cover; + /* `contain`, not `cover`: box art spans everything from near-square SNES + boxes to tall N64 ones, and cropping to a single ratio slices the title + off the edges. Letterboxing against the tile background shows the whole + cover, which is what the user is scanning for. */ + object-fit: contain; display: block; } diff --git a/tools/cover-art/__pycache__/fetch_art.cpython-314.pyc b/tools/cover-art/__pycache__/fetch_art.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25af11d566f2b322230f8ddf4919b214f3d677bf GIT binary patch literal 22485 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 literal 0 HcmV?d00001 diff --git a/tools/cover-art/fetch_art.py b/tools/cover-art/fetch_art.py new file mode 100644 index 0000000..f49917f --- /dev/null +++ b/tools/cover-art/fetch_art.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Fetch box art for the library and attach it to each game. + +Art comes from libretro-thumbnails (https://thumbnails.libretro.com), a +community archive of boxart named to the No-Intro / Redump conventions. It needs +no API key, but covers retro consoles only — Xbox 360 has no thumbnail set, so +those titles are reported as unsupported rather than mismatched. + +Images are pushed through the app's own POST /api/images endpoint, so they get +the same validation, WebP re-encoding and per-user filing as a manual upload. + +Standard library only, so it runs without a virtualenv. + + python3 fetch_art.py --password '...' --dry-run # report, change nothing + python3 fetch_art.py --password '...' # download and attach +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import re +import sys +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +THUMBNAIL_HOST = "https://thumbnails.libretro.com" + +# Our system codes to libretro's DAT-derived directory names. +# Xbox 360 is deliberately absent: libretro-thumbnails has no set for it. +# Several directories may back one system code. Our "GB" covers both Game Boy +# and Game Boy Color titles — Pokemon Trading Card Game, for instance, is only in +# the Color set — so both are searched and the better match wins. +SYSTEM_DIRS = { + "NES": ["Nintendo - Nintendo Entertainment System"], + "SNES": ["Nintendo - Super Nintendo Entertainment System"], + "N64": ["Nintendo - Nintendo 64"], + "GB": ["Nintendo - Game Boy", "Nintendo - Game Boy Color"], + "GBA": ["Nintendo - Game Boy Advance"], + "DS": ["Nintendo - Nintendo DS"], + "GC": ["Nintendo - GameCube"], + "WII": ["Nintendo - Wii"], + "PS1": ["Sony - PlayStation"], + "PS2": ["Sony - PlayStation 2"], + "PSP": ["Sony - PlayStation Portable"], +} + +# Preferred release region, best first. The library is a US collection, so a USA +# release wins; a European box is better than nothing. +REGION_RANK = ["usa", "world", "usa, europe", "europe", "japan, usa", "japan"] + +# Tags that mark a variant we would rather not pick when a plain release exists. +UNDESIRABLE_TAGS = ( + "beta", "proto", "demo", "sample", "virtual console", "switch online", + "classic mini", "rev ", "alt", "unl", "aftermarket", "competition", +) + +ROMAN = { + "i": 1, "ii": 2, "iii": 3, "iv": 4, "v": 5, "vi": 6, "vii": 7, "viii": 8, + "ix": 9, "x": 10, "xi": 11, "xii": 12, "xiii": 13, "xiv": 14, "xv": 15, +} + + +# -------------------------------------------------------------------------- +# Title normalisation +# -------------------------------------------------------------------------- + +def strip_accents(text: str) -> str: + """'Pokémon' -> 'Pokemon', so our un-accented rows still match.""" + return "".join( + c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c) + ) + + +def normalise(title: str) -> str: + """Reduce a title to a comparable form. + + Roman numerals become digits, which is what makes our SNES 'Final Fantasy 2' + line up with the catalogued 'Final Fantasy II', and the PS1 'Final Fantasy V' + with 'Final Fantasy V' rather than drifting to a different entry. + """ + text = strip_accents(title).lower() + text = text.replace("&", " and ") + # The catalogue moves a leading article to the end: "Sims 2, The", + # "Legend of Zelda, The - Ocarina of Time". Drop it before anything else, + # while the comma that marks it is still there to find. + text = re.sub(r",\s*(the|a|an)\b", " ", text) + # libretro renders a subtitle colon as " - "; flatten both to a space. + text = re.sub(r"\s+-\s+", " ", text) + text = re.sub(r"[^a-z0-9]+", " ", text) + + words = [str(ROMAN.get(w, w)) for w in text.split()] + # Leading articles carry no signal and differ between catalogues. + while words and words[0] in ("the", "a", "an"): + words.pop(0) + return " ".join(words).strip() + + +@dataclass +class Candidate: + filename: str + base: str # title with all parenthetical tags removed + tags: str # the tags, lowercased, for region and variant ranking + directory: str # libretro set it came from, needed to build the download URL + + @property + def region_rank(self) -> int: + for i, region in enumerate(REGION_RANK): + if region in self.tags: + return i + return len(REGION_RANK) + + @property + def variant_penalty(self) -> int: + return sum(1 for tag in UNDESIRABLE_TAGS if tag in self.tags) + + +def parse_candidate(filename: str, directory: str) -> Candidate: + stem = filename[:-4] if filename.lower().endswith(".png") else filename + tags = " ".join(re.findall(r"\(([^)]*)\)", stem)).lower() + base = re.sub(r"\s*\([^)]*\)", "", stem).strip() + return Candidate(filename=filename, base=base, tags=tags, directory=directory) + + +# -------------------------------------------------------------------------- +# HTTP +# -------------------------------------------------------------------------- + +def http(url: str, *, data=None, headers=None, method=None, timeout=90) -> bytes: + request = urllib.request.Request(url, data=data, method=method) + for key, value in (headers or {}).items(): + request.add_header(key, value) + last_error: Exception | None = None + + for attempt in range(3): + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + except urllib.error.HTTPError as exc: + # 4xx will not improve on retry; surface it immediately. + if exc.code < 500: + raise + last_error = exc + except (urllib.error.URLError, TimeoutError) as exc: + last_error = exc + time.sleep(1.5 * (attempt + 1)) + + raise RuntimeError(f"GET {url} failed after 3 attempts: {last_error}") + + +def api_json(base: str, path: str, token: str | None = None, *, data=None, method=None): + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + body = None + if data is not None: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + raw = http(base + path, data=body, headers=headers, method=method) + return json.loads(raw) if raw else None + + +def upload_image(base: str, token: str, filename: str, blob: bytes) -> dict: + """multipart/form-data POST, hand-rolled to avoid a requests dependency.""" + boundary = "----LudosArt" + str(int(time.time() * 1000)) + body = b"".join([ + f'--{boundary}\r\n'.encode(), + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(), + b"Content-Type: image/png\r\n\r\n", + blob, + f"\r\n--{boundary}--\r\n".encode(), + ]) + raw = http( + base + "/api/images", + data=body, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + return json.loads(raw) + + +# -------------------------------------------------------------------------- +# Listings +# -------------------------------------------------------------------------- + +def load_listing(system: str, cache_dir: Path) -> list[Candidate]: + """Every candidate for a system, across all of its libretro sets. + + Listings are cached on disk, so reruns and dry-runs cost nothing. + """ + candidates: list[Candidate] = [] + + for directory in SYSTEM_DIRS[system]: + cache = cache_dir / f"{directory}.json" + if cache.exists(): + names = json.loads(cache.read_text()) + else: + quoted = urllib.parse.quote(directory) + html = http(f"{THUMBNAIL_HOST}/{quoted}/Named_Boxarts/").decode( + "utf-8", errors="replace" + ) + names = sorted( + {urllib.parse.unquote(m) for m in re.findall(r'href="([^"]+\.png)"', html)} + ) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(names, indent=0)) + + candidates.extend(parse_candidate(n, directory) for n in names) + + return candidates + + +def contains_tokens(haystack: list[str], needle: list[str]) -> bool: + """True when `needle` appears as a contiguous run inside `haystack`.""" + if not needle or len(needle) > len(haystack): + return False + return any( + haystack[i:i + len(needle)] == needle + for i in range(len(haystack) - len(needle) + 1) + ) + + +def best_match(title: str, candidates: list[Candidate]) -> tuple[Candidate | None, float]: + """Highest-scoring candidate, with region and variant used as tie-breakers.""" + target = normalise(title) + if not target: + return None, 0.0 + target_tokens = target.split() + + scored: list[tuple[float, int, int, int, Candidate]] = [] + for candidate in candidates: + base = normalise(candidate.base) + score = difflib.SequenceMatcher(None, target, base).ratio() + base_tokens = base.split() + extra = len(base_tokens) - len(target_tokens) + + # Subtitles go missing in both directions. Our rows sometimes omit what + # the catalogue carries ("Wave Race 64" vs "Wave Race 64 - Kawasaki Jet + # Ski") and sometimes carry what it omits (our "Donkey Kong Country 2: + # Diddy's Kong Quest" vs the GBA set's "Donkey Kong Country 2"). Either + # way a clean token-run containment is a strong signal, so compare + # whichever is shorter against whichever is longer. + # + # Capped below 0.95 so a genuine exact title always outranks it, and + # scaled by coverage so the closest-length candidate wins among several + # ("Donkey Kong Country 3" beats a bare "Donkey Kong Country"). + # Guard against collapsing a sequel onto its base game. If we are asking + # for a number the candidate does not have — "Donkey Kong Country 2" + # against a plain "Donkey Kong Country" — containment would happily match + # the wrong box. Extra numbers on the candidate side are fine, since that + # is just a series prefix ("Super Mario World 2 - Yoshi's Island"). + target_numbers = {t for t in target_tokens if t.isdigit()} + base_numbers = {t for t in base_tokens if t.isdigit()} + sequel_mismatch = bool(target_numbers - base_numbers) + + shorter, longer = sorted((target_tokens, base_tokens), key=len) + if extra != 0 and not sequel_mismatch and contains_tokens(longer, shorter): + coverage = len(shorter) / len(longer) + score = max(score, 0.88 + 0.06 * coverage) + + if score >= 0.80: + scored.append( + (score, -candidate.region_rank, -abs(extra), -candidate.variant_penalty, candidate) + ) + + if not scored: + return None, 0.0 + + # Similarity first, then the US release, the closest-length title, and the + # plain variant over a revision or re-release. + scored.sort(key=lambda t: (round(t[0], 3), t[1], t[2], t[3]), reverse=True) + score, _, _, _, candidate = scored[0] + return candidate, score + + +# -------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--api", default="http://localhost:8080", help="API base URL") + parser.add_argument("--user", default="ckoch") + parser.add_argument("--password", required=True) + parser.add_argument("--dry-run", action="store_true", help="report matches, change nothing") + parser.add_argument("--overwrite", action="store_true", help="replace art that is already set") + parser.add_argument("--min-score", type=float, default=0.80, + help="similarity below which a match is not applied (0-1)") + parser.add_argument("--cache", default=str(Path(__file__).parent / ".cache")) + args = parser.parse_args() + + base = args.api.rstrip("/") + cache_dir = Path(args.cache) + + print("Signing in…") + auth = api_json(base, "/api/auth/login", data={"userName": args.user, "password": args.password}) + token = auth["token"] + + print("Fetching library…") + games: list[dict] = [] + page = 1 + while True: + result = api_json(base, f"/api/games?page={page}&pageSize=100", token) + games.extend(result["items"]) + if page >= result["totalPages"] or not result["items"]: + break + page += 1 + print(f" {len(games)} games\n") + + systems = sorted({g["system"] for g in games if g["system"]}) + listings: dict[str, list[Candidate]] = {} + for system in systems: + if system not in SYSTEM_DIRS: + continue + print(f"Loading {system} catalogue…", end=" ", flush=True) + listings[system] = load_listing(system, cache_dir) + print(f"{len(listings[system])} covers") + print() + + applied = skipped = failed = 0 + unsupported: list[dict] = [] + weak: list[tuple[dict, str, float]] = [] + + for game in sorted(games, key=lambda g: g["title"].lower()): + title, system = game["title"], game["system"] + + if game.get("art") and not args.overwrite: + skipped += 1 + continue + + if not system or system not in SYSTEM_DIRS: + unsupported.append(game) + continue + + candidate, score = best_match(title, listings[system]) + if not candidate or score < args.min_score: + print(f" ? {system:4} {title[:46]:48} no match") + failed += 1 + continue + + flag = " " if score >= 0.95 else "~" + print(f" {flag} {system:4} {title[:46]:48} {score:.2f} {candidate.filename[:52]}") + if score < 0.95: + weak.append((game, candidate.filename, score)) + + if args.dry_run: + applied += 1 + continue + + try: + directory = urllib.parse.quote(candidate.directory) + name = urllib.parse.quote(candidate.filename) + blob = http(f"{THUMBNAIL_HOST}/{directory}/Named_Boxarts/{name}") + + uploaded = upload_image(base, token, candidate.filename, blob) + + payload = {k: game.get(k) for k in ( + "title", "system", "genre", "year", "developer", "publisher", + "description", "own", "dumped", "played", "finished")} + payload["art"] = uploaded["fileName"] + api_json(base, f"/api/games/{game['id']}", token, data=payload, method="PUT") + applied += 1 + except Exception as exc: # noqa: BLE001 - report and continue the batch + print(f" -> FAILED: {exc}") + failed += 1 + + # ---- report ---------------------------------------------------------- + print("\n" + "=" * 72) + verb = "would attach" if args.dry_run else "attached" + print(f"{verb}: {applied} already had art: {skipped} no match: {failed} " + f"unsupported system: {len(unsupported)}") + + if weak: + print(f"\nWorth eyeballing in the UI — matched below 0.95 similarity ({len(weak)}):") + for game, filename, score in sorted(weak, key=lambda w: w[2]): + print(f" {score:.2f} {game['system']:4} {game['title'][:40]:42} -> {filename[:50]}") + + if unsupported: + systems_missing = sorted({g['system'] or '(none)' for g in unsupported}) + print(f"\nNo libretro thumbnail set for: {', '.join(systems_missing)} " + f"({len(unsupported)} games). These need IGDB:") + for game in unsupported[:15]: + print(f" {game['system'] or '-':4} {game['title']}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())