#!/usr/bin/env python3 """ Cross-reference a Radarr library against the Criterion Collection. Matches on TMDB ID (primary) with title+year fallback. Usage: export RADARR_URL="http://radarr.local:7878" export RADARR_API_KEY="your-radarr-api-key" python criterion_crossref.py [--fast] [--out criterion_crossref.json] --fast skip per-film TMDB ID resolution (title+year matching only, much quicker) --out where to write the JSON result (default: ./criterion_crossref.json) Requires only the Python standard library. """ import os import re import sys import json import time import html import urllib.request import urllib.error from difflib import SequenceMatcher RADARR_URL = os.environ.get("RADARR_URL", "").rstrip("/") RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") # Letterboxd list that carries spine numbers + TMDB links in markup CRITERION_LIST_URL = "https://letterboxd.com/jbutts15/list/the-complete-criterion-collection/" HEADERS = {"User-Agent": "Mozilla/5.0 (criterion-crossref/1.0)"} def die(msg): print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def http_get(url, headers=None): req = urllib.request.Request(url, headers=headers or HEADERS) try: with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode("utf-8", errors="replace") except urllib.error.HTTPError as e: die(f"HTTP {e.code} fetching {url}") except urllib.error.URLError as e: die(f"Network error fetching {url}: {e.reason}") # --------------------------------------------------------------------------- # Radarr # --------------------------------------------------------------------------- def get_radarr_movies(): if not RADARR_URL: die("RADARR_URL not set (e.g. http://radarr.local:7878).") if not RADARR_API_KEY: die("RADARR_API_KEY not set (Radarr → Settings → General → API Key).") url = f"{RADARR_URL}/api/v3/movie" data = json.loads(http_get(url, headers={**HEADERS, "X-Api-Key": RADARR_API_KEY})) movies = [] for m in data: movies.append({ "title": m.get("title", ""), "year": m.get("year"), "tmdbId": m.get("tmdbId"), "hasFile": m.get("hasFile", False), }) return movies # --------------------------------------------------------------------------- # Criterion list (Letterboxd) — paginated, scrapes TMDB IDs where present # --------------------------------------------------------------------------- def get_criterion_films(): films = [] page = 1 seen_slugs = set() while True: url = CRITERION_LIST_URL if page == 1 else f"{CRITERION_LIST_URL}page/{page}/" htmltext = http_get(url) # Each film entry has data-target-link="/film/SLUG/" and an img alt="TITLE" poster_blocks = re.findall( r'data-target-link="/film/([^"]+)/"[^>]*>.*?' r'alt="([^"]*)"', htmltext, re.DOTALL, ) new = 0 for slug, name in poster_blocks: if slug in seen_slugs: continue seen_slugs.add(slug) new += 1 # Try to extract year from slug (e.g. "frankenstein-2025") year_match = re.search(r'-(\d{4})$', slug) films.append({ "slug": slug, "title": html.unescape(name) if name else slug.replace("-", " ").title(), "year": int(year_match.group(1)) if year_match else None, "tmdbId": None, # resolved lazily below if needed }) if new == 0: break page += 1 time.sleep(0.5) # be polite return films def resolve_tmdb_id(slug): """Fetch a Letterboxd film page and extract its TMDB id.""" url = f"https://letterboxd.com/film/{slug}/" try: page = http_get(url) except SystemExit: return None m = re.search(r'themoviedb\.org/movie/(\d+)', page) return int(m.group(1)) if m else None # --------------------------------------------------------------------------- # Matching # --------------------------------------------------------------------------- def norm(s): s = s.lower() s = re.sub(r"[^a-z0-9 ]", "", s) s = re.sub(r"\s+", " ", s).strip() return s def crossref(radarr, criterion, resolve_ids=True): tmdb_owned = {m["tmdbId"]: m for m in radarr if m.get("tmdbId")} title_index = {} for m in radarr: title_index.setdefault(norm(m["title"]), []).append(m) owned, missing, fuzzy = [], [], [] for i, film in enumerate(criterion, 1): matched = None # 1) Try TMDB ID match (resolve lazily to avoid hammering every film) if resolve_ids and film["tmdbId"] is None: film["tmdbId"] = resolve_tmdb_id(film["slug"]) time.sleep(0.25) if film["tmdbId"] and film["tmdbId"] in tmdb_owned: matched = tmdb_owned[film["tmdbId"]] # 2) Exact normalised title+year if not matched: for cand in title_index.get(norm(film["title"]), []): if film["year"] is None or cand["year"] == film["year"]: matched = cand break if matched: owned.append((film, matched)) continue # 3) Fuzzy near-match for manual review best, best_ratio = None, 0.0 nf = norm(film["title"]) for m in radarr: r = SequenceMatcher(None, nf, norm(m["title"])).ratio() if r > best_ratio: best_ratio, best = r, m if best_ratio >= 0.82: fuzzy.append((film, best, best_ratio)) else: missing.append(film) if i % 50 == 0: print(f" ...processed {i}/{len(criterion)}", file=sys.stderr) return owned, missing, fuzzy def main(): args = sys.argv[1:] resolve_ids = "--fast" not in args out_path = "criterion_crossref.json" if "--out" in args: idx = args.index("--out") if idx + 1 >= len(args): die("--out needs a file path") out_path = args[idx + 1] print("Fetching Radarr library...", file=sys.stderr) radarr = get_radarr_movies() print(f" {len(radarr)} movies in Radarr", file=sys.stderr) print("Fetching Criterion list...", file=sys.stderr) criterion = get_criterion_films() print(f" {len(criterion)} Criterion titles", file=sys.stderr) # ID resolution is the slow part; --fast does a title-only pass owned, missing, fuzzy = crossref(radarr, criterion, resolve_ids=resolve_ids) print("\n" + "=" * 60) print(f"OWNED: {len(owned)} / {len(criterion)}") print("=" * 60) for film, m in sorted(owned, key=lambda x: x[0]["title"].lower()): flag = "" if m["hasFile"] else " [MONITORED, NO FILE]" print(f" {film['title']} ({film['year'] or '?'}){flag}") print("\n" + "=" * 60) print(f"FUZZY MATCHES (review these): {len(fuzzy)}") print("=" * 60) for film, m, r in sorted(fuzzy, key=lambda x: -x[2]): print(f" {film['title']} ({film['year'] or '?'}) ~= " f"{m['title']} ({m['year']}) [{r:.0%}]") print("\n" + "=" * 60) print(f"MISSING: {len(missing)}") print("=" * 60) for film in sorted(missing, key=lambda x: x["title"].lower()): print(f" {film['title']} ({film['year'] or '?'})") # JSON dump for the other scripts in this toolkit with open(out_path, "w") as f: json.dump({ "owned": [{"criterion": c, "radarr": m} for c, m in owned], "fuzzy": [{"criterion": c, "radarr": m, "ratio": r} for c, m, r in fuzzy], "missing": missing, }, f, indent=2) print(f"\nWrote {out_path}", file=sys.stderr) if __name__ == "__main__": main()