"""Per-item metadata: cached fetches + user overrides. Fetched metadata (game details from IGDB, movie details from TheMovieDB / Wikipedia) is cached in the library DB keyed by ``(release_id, system)`` so it is only ever requested once. Users can also hand-tag an item; the override is stored with ``manual=1`` and always wins over both the cache and fresh fetches. All database calls are defensive: a transient I/O failure returns ``None`` / ``False`` instead of raising, so a flaky share never crashes the UI. """ import json import sqlite3 import time from . import config, covers, server_client from .db import get_connection _failed = {} # (release_id, system) -> time.time() of last failed fetch _FAIL_TTL = 1800.0 # don't re-fetch failed metadata for 30 minutes # Persistent "no metadata" cache so a failed lookup isn't re-tried on restart. _miss_db = {} _miss_loaded = False _MISS_TTL = 86400.0 # retry failed metadata after 1 day # Memoised release-date table (invalidated on save/reset). _dates_cache = None 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 meta_attempts WHERE status = 'missing'" ): _miss_db[(r["key"], r["system"])] = r["ts"] conn.close() except sqlite3.Error: 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 meta_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 sqlite3.Error: pass def _clear_missing(key, system): _miss_db.pop((key, system), None) try: conn = get_connection(config.DB_PATH) conn.execute( "DELETE FROM meta_attempts WHERE key = ? AND system = ?", (key, system) ) conn.commit() conn.close() except sqlite3.Error: pass def _serialize(meta): """Strip non-JSON-able or helper keys before persisting.""" return {k: v for k, v in (meta or {}).items() if k != "manual"} def load(release_id, system): """Return the stored info dict (with ``manual`` set) or None.""" try: conn = get_connection(config.DB_PATH) row = conn.execute( "SELECT title, meta_json, manual FROM item_info WHERE release_id = ? AND system = ?", (str(release_id), system), ).fetchone() conn.close() except sqlite3.Error: return None if row is None: return None meta = {} try: meta = json.loads(row["meta_json"] or "{}") except (TypeError, ValueError): meta = {} if not isinstance(meta, dict): meta = {} if row["title"]: meta["title"] = row["title"] meta["manual"] = bool(row["manual"]) return meta def save(release_id, system, meta, manual=False, title=None): """Persist info for an item. ``manual`` marks a user override.""" try: conn = get_connection(config.DB_PATH) conn.execute( """ INSERT INTO item_info (release_id, system, title, meta_json, manual, updated_at) VALUES (?,?,?,?,?,datetime('now')) ON CONFLICT(release_id, system) DO UPDATE SET title=excluded.title, meta_json=excluded.meta_json, manual=excluded.manual, updated_at=excluded.updated_at """, ( str(release_id), system, title if title else (meta.get("title") if manual else None), json.dumps(_serialize(meta)), 1 if manual else 0, ), ) conn.commit() conn.close() _invalidate_dates_cache() return True except sqlite3.Error: return False def reset(release_id, system): """Delete cached/overridden info so it is re-fetched next time.""" key = (str(release_id), system) _failed.pop(key, None) _clear_missing(*key) try: conn = get_connection(config.DB_PATH) conn.execute( "DELETE FROM item_info WHERE release_id = ? AND system = ?", (str(release_id), system), ) conn.commit() conn.close() _invalidate_dates_cache() except sqlite3.Error: pass def override_titles(): """Return {(system, str(release_id)): title} for all manual overrides.""" try: conn = get_connection(config.DB_PATH) rows = conn.execute( "SELECT release_id, system, title FROM item_info WHERE manual = 1 AND title IS NOT NULL" ).fetchall() conn.close() except sqlite3.Error: return {} return {(r["system"], str(r["release_id"])): r["title"] for r in rows} def cached_release_dates(): """Return {(release_id, system): release_date} from cached metadata only. Pure DB read — no network — so list views can show a release date without triggering API fetches for every row. Result is memoised and invalidated whenever metadata is saved. """ global _dates_cache if _dates_cache is None: out = {} try: conn = get_connection(config.DB_PATH) rows = conn.execute("SELECT release_id, system, meta_json FROM item_info").fetchall() conn.close() except sqlite3.Error: return out for r in rows: try: meta = json.loads(r["meta_json"] or "{}") except (TypeError, ValueError): continue if isinstance(meta, dict) and meta.get("release_date"): out[(r["release_id"], r["system"])] = meta["release_date"] _dates_cache = out return _dates_cache def _invalidate_dates_cache(): global _dates_cache _dates_cache = None def get_or_fetch(release_id, system, fetch_title): """Return cached info (override or fetched) or fetch-and-cache it.""" info = load(release_id, system) if info is not None: return info # Server is authoritative for metadata when configured — no IGDB/TMDB # fallback; an unreachable server just leaves metadata empty. if server_client.configured(): try: meta = server_client.fetch_metadata(system, fetch_title) except Exception as exc: # noqa: BLE001 log.warning("server metadata fetch failed for %r: %s", fetch_title, exc) return None if meta: save(release_id, system, meta, manual=False) meta["manual"] = False return meta return None key = (str(release_id), system) last = _failed.get(key) if last is not None and time.time() - last < _FAIL_TTL: return None # recently failed; don't hammer the API again _load_miss_db() miss_ts = _miss_db.get(key) if miss_ts is not None and time.time() - miss_ts < _MISS_TTL: return None try: if system == "videos": meta = covers.get_movie_metadata(fetch_title) else: meta = covers.get_game_metadata(fetch_title, system=system) except Exception: # noqa: BLE001 - network/API failures must not crash the UI meta = None if meta: save(release_id, system, meta, manual=False) meta["manual"] = False return meta _failed[key] = time.time() _mark_missing(*key) return None