#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ media_date_cache.py Builds and applies "best possible" dates to media files, caching choices in SQLite. Sources, in priority order: TV (kind=tv): 1) Sonarr first-import date (history and/or episodeFile.dateAdded) 2) Sonarr episode airDateUtc 3) TMDB episode air_date (via folder IMDb ID) 4) (optional) file mtime fallback if --fallback file_mtime Movies (kind=movie): 1) Radarr first-import (history and/or movieFile.dateAdded) 2) Radarr release dates (digital -> physical -> theatrical) 3) TMDB release dates (digital -> physical -> theatrical) via folder IMDb ID 4) (optional) file mtime fallback if --fallback file_mtime Environment (.env supported): SONARR_URL, SONARR_API_KEY RADARR_URL, RADARR_API_KEY TMDB_API_KEY TMDB_PRIMARY_COUNTRY=US GOTIFY_URL, GOTIFY_TOKEN, GOTIFY_PRIORITY=5 GOTIFY_TICK_EVERY=500 TV_ROOTS=/mnt/unionfs/Media/TV:/mnt/unionfs/Media/TV/tv:/mnt/unionfs/Media/TV/tv6 MOVIE_ROOTS=/mnt/unionfs/Media/Movies:/mnt/unionfs/Media/Movies/movies:/mnt/unionfs/Media/Movies/movies6 Usage examples: python media_date_cache.py apply --kind tv --db /opt/scripts/unmanic/.cache/media_dates.db --root /mnt/unionfs/Media/TV --live-on-miss python media_date_cache.py apply --kind movie --db /opt/scripts/unmanic/.cache/media_dates.db --root /mnt/unionfs/Media/Movies --live-on-miss Requires: pip install requests python-dotenv """ import argparse import datetime as dt import json import os import re import sqlite3 import sys import time from pathlib import Path from typing import Dict, List, Optional, Tuple try: from dotenv import load_dotenv except Exception: load_dotenv = None import requests VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"} # ---------- Utilities ---------- def log(msg: str): now = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[{now}] {msg}", flush=True) def load_env(env_file: Optional[str] = None) -> Dict[str, str]: if env_file and os.path.isfile(env_file): if load_dotenv: load_dotenv(env_file) else: # Auto-load .env from cwd if present local = Path(".env") if local.exists() and load_dotenv: load_dotenv(local) return dict(os.environ) def env_get(env: Dict[str, str], key: str, default: Optional[str] = None) -> Optional[str]: val = env.get(key, default) return val def env_get_int(env: Dict[str, str], key: str, default: int) -> int: raw = str(env.get(key, str(default)) or str(default)) # tolerate trailing comments like: "5 # optional" raw = raw.strip().split()[0] try: return int(raw) except Exception: return default def parse_utc(ts: str) -> Optional[int]: if not ts: return None # Try a few common formats for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"): try: if fmt.endswith("Z"): dt_obj = dt.datetime.strptime(ts, fmt).replace(tzinfo=dt.timezone.utc) elif fmt == "%Y-%m-%d": # interpret bare dates as midnight UTC dt_obj = dt.datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=dt.timezone.utc) else: dt_obj = dt.datetime.strptime(ts, fmt) return int(dt_obj.timestamp()) except Exception: continue return None def dt_to_epoch_utc(d: dt.datetime) -> int: if d.tzinfo is None: d = d.replace(tzinfo=dt.timezone.utc) return int(d.timestamp()) def safe_join_paths(paths: List[str]) -> List[str]: out = [] for p in paths: if p and os.path.isdir(p): out.append(p) return out IMDB_RX = re.compile(r"(?:^|\W)(?:imdb-)?(tt\d{6,9})(?:\W|$)", re.IGNORECASE) SXXEYY_RX = re.compile(r"[Ss](\d{1,3})[ ._-]*[Ee](\d{1,3})") def extract_imdb_from_path(path: str) -> Optional[str]: # Search each segment (show or movie folder often has it) for part in Path(path).parts[::-1]: m = IMDB_RX.search(part) if m: return m.group(1).lower() return None def parse_sxxeyy(path: str) -> Optional[Tuple[int, int]]: m = SXXEYY_RX.search(path) if not m: return None try: return int(m.group(1)), int(m.group(2)) except Exception: return None # ---------- Gotify ---------- def gotify_send(env: Dict[str, str], title: str, message: str, priority: Optional[int] = None): url = env_get(env, "GOTIFY_URL") token = env_get(env, "GOTIFY_TOKEN") if not url or not token: return prio = priority if priority is not None else env_get_int(env, "GOTIFY_PRIORITY", 5) try: r = requests.post( f"{url.rstrip('/')}/message", headers={"X-Gotify-Key": token}, json={"title": title, "message": message, "priority": prio}, timeout=10, ) r.raise_for_status() except Exception as e: log(f"GOTIFY WARN: {e}") # ---------- SQLite ---------- DDL_MEDIA_DATES = """ CREATE TABLE IF NOT EXISTS media_dates ( path TEXT PRIMARY KEY, kind TEXT NOT NULL, chosen_ts INTEGER NOT NULL, chosen_src TEXT NOT NULL, extra_json TEXT, first_seen INTEGER NOT NULL, last_update INTEGER NOT NULL ); """ IDX_MEDIA_DATES_KIND = "CREATE INDEX IF NOT EXISTS idx_media_dates_kind ON media_dates(kind);" def ensure_db(db_path: str): os.makedirs(os.path.dirname(db_path), exist_ok=True) with sqlite3.connect(db_path) as cx: cx.execute(DDL_MEDIA_DATES) cx.execute(IDX_MEDIA_DATES_KIND) cx.commit() def db_get_row(cx: sqlite3.Connection, path: str) -> Optional[Tuple]: cur = cx.execute("SELECT path, kind, chosen_ts, chosen_src, extra_json, first_seen, last_update FROM media_dates WHERE path=?", (path,)) return cur.fetchone() def db_upsert(cx: sqlite3.Connection, path: str, kind: str, chosen_ts: int, chosen_src: str, extra: dict): now = int(time.time()) prev = db_get_row(cx, path) if prev: first_seen = prev[5] cx.execute( "UPDATE media_dates SET chosen_ts=?, chosen_src=?, extra_json=?, last_update=? WHERE path=?", (chosen_ts, chosen_src, json.dumps(extra, ensure_ascii=False), now, path), ) else: first_seen = now cx.execute( "INSERT INTO media_dates (path, kind, chosen_ts, chosen_src, extra_json, first_seen, last_update) VALUES (?,?,?,?,?,?,?)", (path, kind, chosen_ts, chosen_src, json.dumps(extra, ensure_ascii=False), first_seen, now), ) # ---------- API clients (Sonarr, Radarr, TMDB) ---------- def _req_json(method: str, url: str, headers=None, params=None, timeout=30): r = requests.request(method, url, headers=headers or {}, params=params or {}, timeout=timeout) r.raise_for_status() return r.json() class SonarrClient: def __init__(self, base: Optional[str], key: Optional[str]): self.base = base.rstrip("/") if base else None self.key = key self._series = None # list self._episodes = {} # seriesId -> list self._history = {} # seriesId -> list self._epfiles = {} # seriesId -> list def ok(self) -> bool: return bool(self.base and self.key) def _hdr(self): return {"X-Api-Key": self.key} def series(self) -> List[dict]: if self._series is not None: return self._series if not self.ok(): self._series = [] return self._series log("Sonarr: loading series index…") js = _req_json("GET", f"{self.base}/api/v3/series", headers=self._hdr()) if isinstance(js, list): self._series = js else: # defensive self._series = list(js) if js else [] log(f"Sonarr: indexed {len(self._series)} series") return self._series def episodes_for(self, series_id: int) -> List[dict]: if series_id in self._episodes: return self._episodes[series_id] if not self.ok(): self._episodes[series_id] = [] return self._episodes[series_id] js = _req_json("GET", f"{self.base}/api/v3/episode", headers=self._hdr(), params={"seriesId": series_id}) if isinstance(js, list): out = js elif isinstance(js, dict) and "records" in js: out = js["records"] else: out = [] self._episodes[series_id] = out return out def episode_files_for(self, series_id: int) -> List[dict]: if series_id in self._epfiles: return self._epfiles[series_id] if not self.ok(): self._epfiles[series_id] = [] return self._epfiles[series_id] js = _req_json("GET", f"{self.base}/api/v3/episodefile", headers=self._hdr(), params={"seriesId": series_id}) self._epfiles[series_id] = js if isinstance(js, list) else [] return self._epfiles[series_id] def history_for(self, series_id: int) -> List[dict]: if series_id in self._history: return self._history[series_id] if not self.ok(): self._history[series_id] = [] return self._history[series_id] # series history paginated page = 1 page_size = 500 records: List[dict] = [] while True: js = _req_json( "GET", f"{self.base}/api/v3/history/series", headers=self._hdr(), params={"seriesId": series_id, "includeEpisode": "true", "page": page, "pageSize": page_size}, ) chunk = [] if isinstance(js, dict): chunk = js.get("records") or js.get("results") or [] total = js.get("totalRecords") or js.get("total") or 0 elif isinstance(js, list): chunk = js total = len(js) records.extend(chunk) if isinstance(js, dict) and js.get("page") is not None: # If paginated, stop when we fetched all if len(records) >= total: break page += 1 else: # Not paginated break self._history[series_id] = records return records class RadarrClient: def __init__(self, base: Optional[str], key: Optional[str]): self.base = base.rstrip("/") if base else None self.key = key self._movies = None self._history = None self._files_by_movie = {} def ok(self) -> bool: return bool(self.base and self.key) def _hdr(self): return {"X-Api-Key": self.key} def movies(self) -> List[dict]: if self._movies is not None: return self._movies if not self.ok(): self._movies = [] return self._movies js = _req_json("GET", f"{self.base}/api/v3/movie", headers=self._hdr()) self._movies = js if isinstance(js, list) else [] return self._movies def history(self) -> List[dict]: if self._history is not None: return self._history if not self.ok(): self._history = [] return self._history page = 1 page_size = 500 records: List[dict] = [] while True: js = _req_json("GET", f"{self.base}/api/v3/history", headers=self._hdr(), params={"page": page, "pageSize": page_size}) chunk = [] total = 0 if isinstance(js, dict): chunk = js.get("records") or js.get("results") or [] total = js.get("totalRecords") or js.get("total") or 0 elif isinstance(js, list): chunk = js total = len(js) records.extend(chunk) if isinstance(js, dict) and js.get("page") is not None: if len(records) >= total: break page += 1 else: break self._history = records return records def moviefile_for(self, movie_id: int) -> Optional[dict]: if movie_id in self._files_by_movie: return self._files_by_movie[movie_id] if not self.ok(): self._files_by_movie[movie_id] = None return None js = _req_json("GET", f"{self.base}/api/v3/moviefile", headers=self._hdr(), params={"movieId": movie_id}) # could be list, pick first if any mf = js[0] if isinstance(js, list) and js else None self._files_by_movie[movie_id] = mf return mf class TMDBClient: def __init__(self, api_key: Optional[str], primary_country: str = "US"): self.key = api_key self.primary_country = (primary_country or "US").upper() self.base = "https://api.themoviedb.org/3" self._tv_id_cache: Dict[str, int] = {} # imdb_id -> tmdb_tv_id self._movie_id_cache: Dict[str, int] = {} # imdb_id -> tmdb_movie_id self._season_cache: Dict[Tuple[int, int], dict] = {} # (tv_id, season)-> episodes mapping def ok(self) -> bool: return bool(self.key) def _get(self, path: str, params: dict = None): if not self.ok(): return None params = dict(params or {}) params["api_key"] = self.key return _req_json("GET", f"{self.base}{path}", params=params) def tv_id_by_imdb(self, imdb_id: str) -> Optional[int]: imdb_id = imdb_id.lower() if imdb_id in self._tv_id_cache: return self._tv_id_cache[imdb_id] js = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) tid = None if isinstance(js, dict): tvres = js.get("tv_results") or [] if tvres: tid = tvres[0].get("id") self._tv_id_cache[imdb_id] = tid return tid def movie_id_by_imdb(self, imdb_id: str) -> Optional[int]: imdb_id = imdb_id.lower() if imdb_id in self._movie_id_cache: return self._movie_id_cache[imdb_id] js = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) mid = None if isinstance(js, dict): mres = js.get("movie_results") or [] if mres: mid = mres[0].get("id") self._movie_id_cache[imdb_id] = mid return mid def season_airdates(self, tv_id: int, season_number: int) -> Dict[int, str]: key = (tv_id, season_number) if key in self._season_cache: return self._season_cache[key] js = self._get(f"/tv/{tv_id}/season/{season_number}") mapping: Dict[int, str] = {} if isinstance(js, dict): for ep in js.get("episodes") or []: epno = ep.get("episode_number") ad = ep.get("air_date") if epno and ad: mapping[int(epno)] = ad self._season_cache[key] = mapping return mapping def movie_best_release(self, movie_id: int) -> Optional[Tuple[str, str]]: """ Returns (src_key, release_date_iso) where src_key in {'tmdb_digital','tmdb_physical','tmdb_theatrical'} Preference order: digital(4) -> physical(5) -> theatrical(3), prioritizing primary_country, else any. """ js = self._get(f"/movie/{movie_id}/release_dates") if not isinstance(js, dict): return None results = js.get("results") or [] def pick(for_country: Optional[str]) -> Optional[Tuple[str, str]]: best = {} for entry in results: if for_country and (entry.get("iso_3166_1") != for_country): continue for rel in entry.get("release_dates") or []: rtype = int(rel.get("type") or 0) rdate = rel.get("release_date") if not rdate: continue if rtype == 4: # digital best.setdefault("tmdb_digital", []).append(rdate) elif rtype == 5: # physical best.setdefault("tmdb_physical", []).append(rdate) elif rtype == 3: # theatrical best.setdefault("tmdb_theatrical", []).append(rdate) # choose earliest of preferred buckets for key in ("tmdb_digital", "tmdb_physical", "tmdb_theatrical"): if key in best: return key, sorted(best[key])[0] return None # Try primary country first, then any chosen = pick(self.primary_country) or pick(None) return chosen # ---------- Matching helpers ---------- def longest_prefix_match(target: str, prefix_map: Dict[str, dict]) -> Optional[dict]: """ prefix_map: series_path -> series_obj (Sonarr), or movie_path -> movie_obj (Radarr) Returns the mapped object whose path is the longest prefix of target. """ candidates = [] t = target.lower() for p, obj in prefix_map.items(): pl = p.lower() if t.startswith(pl.rstrip("/") + "/") or t == pl: candidates.append((len(pl), obj)) if not candidates: return None candidates.sort(key=lambda x: x[0], reverse=True) return candidates[0][1] def expand_roots_from_env(env: Dict[str, str], kind: str, cli_roots: List[str]) -> List[str]: roots = [] if cli_roots: roots.extend(cli_roots) else: env_key = "TV_ROOTS" if kind == "tv" else "MOVIE_ROOTS" val = env_get(env, env_key) if val: roots.extend([p.strip() for p in val.split(":") if p.strip()]) # Add known child dirs automatically if they exist expanded = [] for r in roots: expanded.append(r) # Only add children for top-level TV/Movie roots if kind == "tv": for child in ("tv", "tv6"): c = os.path.join(r, child) if os.path.isdir(c): expanded.append(c) else: for child in ("movies", "movies6"): c = os.path.join(r, child) if os.path.isdir(c): expanded.append(c) # dedupe and only keep existing dirs uniq = [] seen = set() for p in expanded: if os.path.isdir(p) and p not in seen: uniq.append(p) seen.add(p) return uniq def walk_media_files(roots: List[str]) -> List[str]: files = [] for root in roots: for dirpath, _, filenames in os.walk(root): for fn in filenames: if Path(fn).suffix.lower() in VIDEO_EXTS: files.append(os.path.join(dirpath, fn)) return files # ---------- Choosing timestamps ---------- def sonarr_choose_for_file(sc: SonarrClient, file_path: str) -> Optional[Tuple[int, str, dict]]: """ If the file belongs to a Sonarr series (by path prefix), try to determine a timestamp: - earliest import (history or episodeFile.dateAdded) - else episode airDateUtc Returns (ts, src, extra) or None """ if not sc.ok(): return None series_index = {s.get("path") or "": s for s in sc.series() if (s.get("path") or "").strip()} series = longest_prefix_match(file_path, series_index) if not series: return None sid = series.get("id") eps = sc.episodes_for(sid) epfiles = sc.episode_files_for(sid) hist = sc.history_for(sid) # Build lookup maps # Map (season,episode) -> episode id and airDateUtc ep_map: Dict[Tuple[int, int], dict] = {} for e in eps: try: k = (int(e.get("seasonNumber") or 0), int(e.get("episodeNumber") or 0)) ep_map[k] = e except Exception: continue # episodeId -> earliest import date (from history) import_map: Dict[int, int] = {} for r in hist: try: ev = (r.get("eventType") or r.get("event") or "") evl = str(ev).lower() if "import" not in evl: continue edate = r.get("date") ts = parse_utc(edate) if ts is None: continue ep_obj = r.get("episode") or {} eid = ep_obj.get("id") if not eid: continue prev = import_map.get(eid) if prev is None or ts < prev: import_map[eid] = ts except Exception: continue # episodeFile dateAdded (another hint) # episodeFile doesn't directly map to episodeId always, but often the filename/relativePath implies SxxEyy. # We'll only use dateAdded as a general earliest among files that share the same S/E when we can parse S/E. # We'll handle per-file below. # Parse S/E from the path se = parse_sxxeyy(file_path) if not se: return None s_num, e_num = se ep = ep_map.get((s_num, e_num)) if not ep: return None eid = ep.get("id") # Candidate 1: history first import for this episode candidates: List[Tuple[int, str]] = [] if eid and eid in import_map: candidates.append((import_map[eid], "sonarr_import")) # Candidate 2: episodeFile.dateAdded for matching S/E if any # Try to match on season/episode parsed from episodeFile.relativePath earliest_epfile_ts = None for ef in epfiles: rel = ef.get("relativePath") or "" se2 = parse_sxxeyy(rel) if se2 == (s_num, e_num): ts2 = parse_utc(ef.get("dateAdded")) if ts2 is not None and (earliest_epfile_ts is None or ts2 < earliest_epfile_ts): earliest_epfile_ts = ts2 if earliest_epfile_ts is not None: candidates.append((earliest_epfile_ts, "episodefile_dateAdded")) # Candidate 3: airDateUtc air_ts = parse_utc(ep.get("airDateUtc") or ep.get("airDate")) if air_ts is not None: candidates.append((air_ts, "tv_airdate")) if not candidates: return None candidates.sort(key=lambda x: x[0]) chosen_ts, chosen_src = candidates[0] extra = { "seriesId": sid, "seriesTitle": series.get("title"), "season": s_num, "episode": e_num, "candidates": [{"ts": ts, "src": src} for ts, src in candidates], } return chosen_ts, chosen_src, extra def radarr_choose_for_file(rc: RadarrClient, file_path: str) -> Optional[Tuple[int, str, dict]]: """ Determine movie timestamp using Radarr: - earliest import from history/movieFile.dateAdded - else Radarr release dates (digital->physical->theatrical) """ if not rc.ok(): return None movies_index = {m.get("path") or "": m for m in rc.movies() if (m.get("path") or "").strip()} movie = longest_prefix_match(file_path, movies_index) if not movie: return None mid = movie.get("id") candidates: List[Tuple[int, str]] = [] # History import events for r in rc.history(): try: ev = (r.get("eventType") or r.get("event") or "") evl = str(ev).lower() if "import" not in evl: continue mobj = r.get("movie") or {} if mobj.get("id") != mid: continue ts = parse_utc(r.get("date")) if ts is not None: candidates.append((ts, "radarr_import")) except Exception: continue # Movie file dateAdded mf = rc.moviefile_for(mid) mf_ts = parse_utc(mf.get("dateAdded")) if mf else None if mf_ts is not None: candidates.append((mf_ts, "moviefile_dateAdded")) # Radarr release dates on the movie object itself # Radarr's movie object has fields like 'digitalRelease', 'physicalRelease', 'inCinemas' for key, src in (("digitalRelease", "movie_digital"), ("physicalRelease", "movie_physical"), ("inCinemas", "movie_cinema")): ts = parse_utc(movie.get(key)) if ts is not None: candidates.append((ts, src)) if not candidates: return None candidates.sort(key=lambda x: x[0]) chosen_ts, chosen_src = candidates[0] extra = { "movieId": mid, "movieTitle": movie.get("title"), "candidates": [{"ts": ts, "src": src} for ts, src in candidates], } return chosen_ts, chosen_src, extra def tmdb_tv_choose(file_path: str, tmdb: TMDBClient) -> Optional[Tuple[int, str, dict]]: """ For a TV episode when Sonarr lacks it: - Extract IMDb ID from path - Map to TMDB tv_id - Parse season/episode from filename - Fetch season's episodes and get air_date for the episode """ if not tmdb.ok(): return None imdb_id = extract_imdb_from_path(file_path) if not imdb_id: return None se = parse_sxxeyy(file_path) if not se: return None s_num, e_num = se tv_id = tmdb.tv_id_by_imdb(imdb_id) if not tv_id: return None airdates = tmdb.season_airdates(tv_id, s_num) iso = airdates.get(e_num) if not iso: return None ts = parse_utc(iso) if ts is None: return None extra = { "imdb": imdb_id, "tmdb_tv_id": tv_id, "season": s_num, "episode": e_num, "tmdb_air_date": iso, } return ts, "tmdb_airdate", extra def tmdb_movie_choose(file_path: str, tmdb: TMDBClient) -> Optional[Tuple[int, str, dict]]: if not tmdb.ok(): return None imdb_id = extract_imdb_from_path(file_path) if not imdb_id: return None movie_id = tmdb.movie_id_by_imdb(imdb_id) if not movie_id: return None picked = tmdb.movie_best_release(movie_id) if not picked: return None src, iso = picked ts = parse_utc(iso) if ts is None: return None extra = {"imdb": imdb_id, "tmdb_movie_id": movie_id, "tmdb_release": {"src": src, "date": iso}} return ts, src, extra # ---------- Apply ---------- def apply_mtime(path: str, ts: int, dry_run: bool = False) -> bool: try: if dry_run: return True os.utime(path, (ts, ts)) return True except Exception as e: log(f"ERROR utime failed for {path}: {e}") return False def apply_command(args): env = load_env(args.env) ensure_db(args.db) # Clients sc = SonarrClient(env_get(env, "SONARR_URL"), env_get(env, "SONARR_API_KEY")) rc = RadarrClient(env_get(env, "RADARR_URL"), env_get(env, "RADARR_API_KEY")) tmdb = TMDBClient(env_get(env, "TMDB_API_KEY"), env_get(env, "TMDB_PRIMARY_COUNTRY") or "US") tick_every = env_get_int(env, "GOTIFY_TICK_EVERY", 500) # Roots roots = expand_roots_from_env(env, args.kind, args.root or []) if not roots: log(f"ERROR: No roots found for kind={args.kind}. Use --root or set TV_ROOTS/MOVIE_ROOTS.") sys.exit(2) files = walk_media_files(roots) log(f"Found {len(files)} media file(s) under {', '.join(roots)}") # If asked, prewarm Sonarr/Radarr series indexes (episodes/history lazy anyway) if args.kind == "tv" and args.live_on_miss: log("Initializing Sonarr (series index only)…") sc.series() # index if args.kind == "movie" and args.live_on_miss: log("Initializing Radarr (movies index only)…") rc.movies() gotify_send(env, "Apply mtimes: start", f"kind={args.kind}, files={len(files)}") updated = 0 checked = 0 misses = 0 with sqlite3.connect(args.db) as cx: for path in files: checked += 1 # Existing cached? row = db_get_row(cx, path) if row and not args.refresh: # Already have it — apply to file (unless skipping) ts, src = row[2], row[3] ok = apply_mtime(path, ts, dry_run=args.dry_run) if ok: updated += 1 if checked % tick_every == 0: gotify_send(env, "Apply mtimes: progress", f"{checked}/{len(files)} (cached)") continue # Need to compute live chosen: Optional[Tuple[int, str, dict]] = None if args.kind == "tv": if args.live_on_miss and sc.ok(): chosen = sonarr_choose_for_file(sc, path) if not chosen: # TMDB fallback chosen = tmdb_tv_choose(path, tmdb) else: # movies if args.live_on_miss and rc.ok(): chosen = radarr_choose_for_file(rc, path) if not chosen: chosen = tmdb_movie_choose(path, tmdb) if not chosen and args.fallback == "file_mtime": try: st = os.stat(path) ts = int(st.st_mtime) chosen = (ts, "file_mtime", {}) except Exception: chosen = None if not chosen: log(f"WARN: no import/release date for {path}") misses += 1 if checked % tick_every == 0: gotify_send(env, "Apply mtimes: progress", f"{checked}/{len(files)} (misses so far: {misses})") continue ts, src, extra = chosen # Upsert DB & apply db_upsert(cx, path, args.kind, ts, src, extra) ok = apply_mtime(path, ts, dry_run=args.dry_run) if ok: updated += 1 if checked % tick_every == 0: gotify_send(env, "Apply mtimes: progress", f"{checked}/{len(files)} (updated: {updated})") cx.commit() gotify_send(env, "Apply mtimes: done", f"kind={args.kind}, updated={updated}, checked={checked}, misses={misses}") log(f"Done. kind={args.kind} checked={checked} updated={updated} misses={misses} dry_run={args.dry_run}") # ---------- Build cache (optional prefetch) ---------- def build_cache_command(args): env = load_env(args.env) ensure_db(args.db) sc = SonarrClient(env_get(env, "SONARR_URL"), env_get(env, "SONARR_API_KEY")) rc = RadarrClient(env_get(env, "RADARR_URL"), env_get(env, "RADARR_API_KEY")) tmdb = TMDBClient(env_get(env, "TMDB_API_KEY"), env_get(env, "TMDB_PRIMARY_COUNTRY") or "US") gotify_send(env, "Cache build: start", f"kind={args.kind}") roots = expand_roots_from_env(env, args.kind, args.root or []) files = walk_media_files(roots) if roots else [] if roots: log(f"Found {len(files)} media file(s) under {', '.join(roots)}") inserted = 0 checked = 0 with sqlite3.connect(args.db) as cx: if args.kind == "tv" and sc.ok(): series = sc.series() total = len(series) for i, s in enumerate(series, start=1): log(f"Sonarr: loading episodes for series {s.get('id')} ({i}/{total})…") eps = sc.episodes_for(s.get("id")) log(f"Sonarr: loading history for series {s.get('id')}…") hist = sc.history_for(s.get("id")) epfiles = sc.episode_files_for(s.get("id")) # We are not mapping specific files here; build-cache is optional. # If roots provided, we will also pass over files and insert rows using live logic: # If roots provided, populate using the same chooser logic as apply but without touching mtimes. if files: for path in files: checked += 1 if db_get_row(cx, path) and not args.refresh: continue chosen = None if args.kind == "tv": if sc.ok(): chosen = sonarr_choose_for_file(sc, path) if not chosen: chosen = tmdb_tv_choose(path, tmdb) else: if rc.ok(): chosen = radarr_choose_for_file(rc, path) if not chosen: chosen = tmdb_movie_choose(path, tmdb) if chosen: ts, src, extra = chosen db_upsert(cx, path, args.kind, ts, src, extra) inserted += 1 elif args.fallback == "file_mtime": try: st = os.stat(path) ts = int(st.st_mtime) db_upsert(cx, path, args.kind, ts, "file_mtime", {}) inserted += 1 except Exception: pass if checked % 500 == 0: gotify_send(env, "Cache build: progress", f"{checked}/{len(files)} cached") cx.commit() gotify_send(env, "Cache build: done", f"kind={args.kind}, inserted={inserted}, scanned={checked or 'n/a'}") log(f"Cache build done. kind={args.kind} inserted={inserted} scanned={checked}") # ---------- CLI ---------- def main(): ap = argparse.ArgumentParser(description="Media date cache & mtime applier (Sonarr/Radarr + TMDB fallback)") sub = ap.add_subparsers(dest="cmd", required=True) common = argparse.ArgumentParser(add_help=False) common.add_argument("--db", required=True, help="SQLite database path") common.add_argument("--env", default=None, help="Path to .env (optional, defaults to ./.env if present)") common.add_argument("--kind", choices=["tv", "movie"], required=True, help="Kind of media this run targets") common.add_argument("--root", action="append", help="Root path(s) to scan; can repeat. If omitted, uses TV_ROOTS/MOVIE_ROOTS.") common.add_argument("--refresh", action="store_true", help="Ignore existing DB rows; recompute live") common.add_argument("--fallback", choices=["none", "file_mtime"], default="none", help="Enable last-resort fallback") common.add_argument("--dry-run", action="store_true", help="Compute and write DB, but do not change file mtimes") ap_apply = sub.add_parser("apply", parents=[common], help="Apply mtimes to files, caching choices") ap_apply.add_argument("--live-on-miss", action="store_true", help="If DB row missing, query Sonarr/Radarr/TMDB live") ap_build = sub.add_parser("build-cache", parents=[common], help="Build/populate cache without touching mtimes") # build-cache shares flags args = ap.parse_args() if args.cmd == "apply": apply_command(args) elif args.cmd == "build-cache": build_cache_command(args) else: ap.print_help() if __name__ == "__main__": main()