#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Media Date Cache (lazy Sonarr/Radarr): - Prefers FIRST import date from history - TV fallback: episode airDateUtc - Movie fallback: digitalRelease, then physicalRelease - Works from DB cache; with --live-on-miss it fetches Sonarr/Radarr lazily per series/movie encounter - Multi-root from .env (TV_ROOTS, MOVIE_ROOTS) and/or --root - Gotify progress notifications """ import os, re, sys, time, json, sqlite3, argparse, traceback from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Tuple try: import requests except ImportError: print("ERROR: 'requests' is required. In your venv: pip install requests", file=sys.stderr) sys.exit(1) # ----------------------------- # Utility / ENV # ----------------------------- ENV_DEFAULT_PATH = os.path.join(os.path.dirname(__file__), ".env") def now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") def log(msg: str): print(f"[{now_iso()}] {msg}", flush=True) def load_env(dotenv_path: str = ENV_DEFAULT_PATH) -> Dict[str, str]: """Minimal .env loader with inline comment stripping.""" env = dict(os.environ) if os.path.exists(dotenv_path): with open(dotenv_path, "r", encoding="utf-8", errors="ignore") as f: for raw in f: line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip() quoted = (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')) if not quoted and "#" in v: v = v.split("#", 1)[0].strip() if quoted: v = v[1:-1] env[k] = v return env def env_get(env: Dict[str, str], key: str, default: Optional[str] = None) -> Optional[str]: return env.get(key, default) def env_get_int(env: Dict[str, str], key: str, default: int) -> int: try: val = env.get(key, None) if val is None: return default return int(str(val).split("#", 1)[0].strip()) except Exception: return default # ----------------------------- # 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: requests.post( url.rstrip("/") + "/message", data={"title": title, "message": message, "priority": prio}, headers={"X-Gotify-Key": token}, timeout=15, ).raise_for_status() except Exception as e: log(f"WARN: gotify failed: {e}") # ----------------------------- # DB # ----------------------------- SCHEMA_SQL = """ PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS files ( path TEXT PRIMARY KEY, ts INTEGER NOT NULL, source TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS episodes ( series_id INTEGER NOT NULL, season INTEGER NOT NULL, episode INTEGER NOT NULL, import_ts INTEGER, airdate_ts INTEGER, PRIMARY KEY(series_id, season, episode) ); CREATE TABLE IF NOT EXISTS movies ( movie_id INTEGER PRIMARY KEY, import_ts INTEGER, digital_ts INTEGER, physical_ts INTEGER ); """ def db_open(db_path: str) -> sqlite3.Connection: os.makedirs(os.path.dirname(db_path), exist_ok=True) conn = sqlite3.connect(db_path) conn.execute("PRAGMA foreign_keys=ON") conn.executescript(SCHEMA_SQL) return conn def db_upsert_file(conn: sqlite3.Connection, path: str, ts: int, source: str): conn.execute( "INSERT INTO files(path, ts, source, updated_at) VALUES(?,?,?,?) " "ON CONFLICT(path) DO UPDATE SET ts=excluded.ts, source=excluded.source, updated_at=excluded.updated_at", (path, ts, source, int(time.time())), ) def db_get_file_ts(conn: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]: cur = conn.execute("SELECT ts, source FROM files WHERE path=?", (path,)) row = cur.fetchone() return (row[0], row[1]) if row else None def db_upsert_episode(conn, series_id, season, episode, import_ts, airdate_ts): conn.execute( "INSERT INTO episodes(series_id, season, episode, import_ts, airdate_ts) VALUES(?,?,?,?,?) " "ON CONFLICT(series_id, season, episode) DO UPDATE SET " "import_ts=COALESCE(excluded.import_ts, episodes.import_ts), " "airdate_ts=COALESCE(excluded.airdate_ts, episodes.airdate_ts)", (series_id, season, episode, import_ts, airdate_ts), ) def db_get_episode(conn, series_id, season, episode): cur = conn.execute( "SELECT import_ts, airdate_ts FROM episodes WHERE series_id=? AND season=? AND episode=?", (series_id, season, episode), ) return cur.fetchone() def db_upsert_movie(conn, movie_id, import_ts, digital_ts, physical_ts): conn.execute( "INSERT INTO movies(movie_id, import_ts, digital_ts, physical_ts) VALUES(?,?,?,?) " "ON CONFLICT(movie_id) DO UPDATE SET " "import_ts=COALESCE(excluded.import_ts, movies.import_ts), " "digital_ts=COALESCE(excluded.digital_ts, movies.digital_ts), " "physical_ts=COALESCE(excluded.physical_ts, movies.physical_ts)", (movie_id, import_ts, digital_ts, physical_ts), ) def db_get_movie(conn, movie_id): cur = conn.execute("SELECT import_ts, digital_ts, physical_ts FROM movies WHERE movie_id=?", (movie_id,)) return cur.fetchone() # ----------------------------- # ARR clients # ----------------------------- class BaseArr: def __init__(self, base: str, api_key: str, timeout: int = 60): self.base = base.rstrip("/") self.session = requests.Session() self.session.headers.update({"X-Api-Key": api_key}) self.timeout = timeout def _get(self, path: str, params: Dict[str, Any] = None): url = self.base + path r = self.session.get(url, params=params or {}, timeout=self.timeout) r.raise_for_status() return r.json() class Sonarr(BaseArr): def all_series(self) -> List[Dict[str, Any]]: data = self._get("/api/v3/series") return data if isinstance(data, list) else [] def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: data = self._get("/api/v3/episode", params={"seriesId": series_id}) return data if isinstance(data, list) else [] def history_page(self, page: int, page_size: int = 250, series_id: Optional[int] = None): params = { "page": page, "pageSize": page_size, "sortDirection": "ascending", "includeEpisode": "true", "includeSeries": "true", } if series_id: params["seriesId"] = series_id data = self._get("/api/v3/history", params=params) if isinstance(data, dict): recs = data.get("records") or data.get("Records") or [] total = int(data.get("totalRecords") or data.get("TotalRecords") or 0) return recs, total return [], 0 def history_all_for_series(self, series_id: int) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] page, size = 1, 250 while True: recs, _ = self.history_page(page, size, series_id=series_id) if not recs: break out.extend(recs) if len(recs) < size: break page += 1 return out class Radarr(BaseArr): def movie_lookup_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]: # Radarr supports term={imdbid:tt1234567} data = self._get("/api/v3/movie/lookup", params={"term": f"imdbid:{imdb_id}"}) if isinstance(data, list) and data: return data[0] return None def movie_by_id(self, movie_id: int) -> Optional[Dict[str, Any]]: try: data = self._get(f"/api/v3/movie/{movie_id}") return data if isinstance(data, dict) else None except Exception: return None def history_all_for_movie(self, movie_id: int) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] page, size = 1, 250 while True: params = { "page": page, "pageSize": size, "sortDirection": "ascending", "includeMovie": "true", "movieId": movie_id, } data = self._get("/api/v3/history", params=params) recs = data.get("records") if isinstance(data, dict) else [] if not recs: break out.extend(recs) if len(recs) < size: break page += 1 return out # ----------------------------- # Parsing helpers # ----------------------------- IMDB_RE = re.compile(r"\bimdb-(tt\d+)\b", re.IGNORECASE) SXXEYY_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{2})(?:[ ._-]*[Ee-]?(\d{2}))?") SEASON_DIR_RE = re.compile(r"[Ss]eason[ _-]*(\d+)") YEAR_PAREN_RE = re.compile(r"\((\d{4})\)") def parse_imdb(s: str) -> Optional[str]: m = IMDB_RE.search(s) return m.group(1).lower() if m else None def parse_series_season_episode(path: str) -> Optional[Tuple[int, List[int]]]: fn = os.path.basename(path) m = SXXEYY_RE.search(fn) if m: season = int(m.group(1)) e1 = int(m.group(2)) e2 = m.group(3) eps = [e1] if not e2 else list(range(e1, int(e2) + 1)) return season, eps # try a parent piece for part in reversed(path.split(os.sep)): m2 = SXXEYY_RE.search(part) if m2: season = int(m2.group(1)) e1 = int(m2.group(2)) e2 = m2.group(3) eps = [e1] if not e2 else list(range(e1, int(e2) + 1)) return season, eps return None def extract_series_folder(path: str) -> str: parts = path.split(os.sep) for i, p in enumerate(parts): if p in ("tv", "tv6"): if i + 1 < len(parts): return parts[i + 1] return os.path.basename(os.path.dirname(os.path.dirname(path))) def extract_title_and_year(name: str) -> Tuple[str, Optional[int]]: year = None m = YEAR_PAREN_RE.search(name) if m: try: year = int(m.group(1)) except: year = None title = name.split("(")[0].strip() if "(" in name else name return title, year def to_epoch(ts_str: Optional[str]) -> Optional[int]: if not ts_str: return None try: s = ts_str.strip() if s.endswith("Z"): s = s.replace("Z", "+00:00") if "+" not in s: s += "+00:00" return int(datetime.fromisoformat(s).timestamp()) except Exception: try: dt = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S.%fZ") return int(dt.replace(tzinfo=timezone.utc).timestamp()) except Exception: return None # ----------------------------- # Roots / scanning # ----------------------------- def split_paths(s: str) -> List[str]: raw = [p.strip() for p in re.split(r"[:,]", s) if p.strip()] return [p for p in raw if os.path.isdir(p)] def resolve_roots(kind: str, root_arg: Optional[str], env: Dict[str, str]) -> List[str]: roots: List[str] = [] if root_arg and os.path.isdir(root_arg): roots.append(root_arg) if kind == "tv": tv_env = env_get(env, "TV_ROOTS", "") if tv_env: roots.extend(split_paths(tv_env)) more = [] for r in list(roots): if os.path.isdir(os.path.join(r, "tv")): more.append(os.path.join(r, "tv")) if os.path.isdir(os.path.join(r, "tv6")): more.append(os.path.join(r, "tv6")) roots.extend(more) else: mv_env = env_get(env, "MOVIE_ROOTS", "") if mv_env: roots.extend(split_paths(mv_env)) more = [] for r in list(roots): if os.path.isdir(os.path.join(r, "movies")): more.append(os.path.join(r, "movies")) if os.path.isdir(os.path.join(r, "movies6")): more.append(os.path.join(r, "movies6")) roots.extend(more) seen, out = set(), [] for p in roots: if p not in seen: seen.add(p); out.append(p) return out or ([root_arg] if root_arg else []) def iter_media_files(roots: List[str]): exts = {".mkv", ".mp4", ".avi", ".m4v", ".mov"} for base in roots: if not base or not os.path.isdir(base): continue for dirpath, _, filenames in os.walk(base): for fn in filenames: if os.path.splitext(fn)[1].lower() in exts: yield os.path.join(dirpath, fn) # ----------------------------- # Lazy Sonarr & Radarr helpers # ----------------------------- def build_sonarr_index(env: Dict[str, str]) -> Tuple[Sonarr, Dict[int, Dict[str, Any]], Dict[str, int], Dict[Tuple[str, Optional[int]], int]]: url = env_get(env, "SONARR_URL", "") key = env_get(env, "SONARR_API_KEY", "") if not url or not key: raise RuntimeError("SONARR_URL/SONARR_API_KEY not set") timeout = env_get_int(env, "ARR_TIMEOUT", 60) client = Sonarr(url, key, timeout=timeout) log("Sonarr: loading series index…") series = client.all_series() series_by_id: Dict[int, Dict[str, Any]] = {} by_imdb: Dict[str, int] = {} title_year_map: Dict[Tuple[str, Optional[int]], int] = {} for s in series: sid = int(s.get("id")) series_by_id[sid] = s imdb = (s.get("imdbId") or "").lower().strip() if imdb: by_imdb[imdb] = sid title = (s.get("title") or "").strip().lower() year = s.get("year") title_year_map[(title, year)] = sid log(f"Sonarr: indexed {len(series_by_id)} series") return client, series_by_id, by_imdb, title_year_map def get_series_id_for_path(path: str, series_by_imdb: Dict[str, int], title_year_map: Dict[Tuple[str, Optional[int]], int]) -> Optional[int]: series_folder = extract_series_folder(path) imdb = parse_imdb(series_folder) if imdb and imdb in series_by_imdb: return series_by_imdb[imdb] title, year = extract_title_and_year(series_folder) return title_year_map.get((title.strip().lower(), year)) def tv_preferred_ts_lazy( path: str, conn: sqlite3.Connection, client: Sonarr, series_by_id: Dict[int, Dict[str, Any]], series_by_imdb: Dict[str, int], title_year_map: Dict[Tuple[str, Optional[int]], int], episodes_cache: Dict[int, Dict[Tuple[int, int], Dict[str, Any]]], earliest_import_cache: Dict[int, Dict[int, int]], ) -> Optional[Tuple[int, str]]: sid = get_series_id_for_path(path, series_by_imdb, title_year_map) if not sid: return None se = parse_series_season_episode(path) if not se: return None season, eps = se # Fetch episodes for this series lazily if sid not in episodes_cache: log(f"Sonarr: loading episodes for series {sid}…") ep_list = client.episodes_for_series(sid) m: Dict[Tuple[int, int], Dict[str, Any]] = {} for e in ep_list: key = (int(e.get("seasonNumber", -1)), int(e.get("episodeNumber", -1))) m[key] = e episodes_cache[sid] = m # Fetch history earliest-import for this series lazily if sid not in earliest_import_cache: log(f"Sonarr: loading history for series {sid}…") recs = client.history_all_for_series(sid) by_epid: Dict[int, int] = {} for r in recs: et = str(r.get("eventType") or "").lower() if "import" not in et: continue ep = r.get("episode") or {} eid = ep.get("id") date = to_epoch(r.get("date")) if eid and date: cur = by_epid.get(eid) by_epid[eid] = min(cur, date) if cur else date earliest_import_cache[sid] = by_epid epmap = episodes_cache.get(sid, {}) by_epid = earliest_import_cache.get(sid, {}) import_ts_list: List[int] = [] air_ts_list: List[int] = [] for e in eps: epi = epmap.get((season, e)) if not epi: continue eid = epi.get("id") air_ts = to_epoch(epi.get("airDateUtc") or epi.get("airDate")) if air_ts: air_ts_list.append(air_ts) if eid and eid in by_epid: import_ts_list.append(by_epid[eid]) # persist to episode table for future runs db_upsert_episode(conn, sid, season, e, by_epid.get(eid), air_ts) if import_ts_list: return min(import_ts_list), "history" if air_ts_list: return min(air_ts_list), "airdate" return None def movie_preferred_ts_lazy( path: str, conn: sqlite3.Connection, client: Radarr, ) -> Optional[Tuple[int, str]]: # Identify movie folder under movies/movies6 parts = path.split(os.sep) movie_folder = "" for i, p in enumerate(parts): if p in ("movies", "movies6") and i + 1 < len(parts): movie_folder = parts[i + 1] break if not movie_folder: movie_folder = os.path.basename(os.path.dirname(path)) imdb = parse_imdb(movie_folder) title, year = extract_title_and_year(movie_folder) movie: Optional[Dict[str, Any]] = None if imdb: movie = client.movie_lookup_by_imdb(imdb) # If no imdb or lookup failed, try guessing by path title/year is not implemented lazily (to avoid /api/v3/movie bulk pull). if not movie: return None movie_id = movie.get("id") if not movie_id: return None # Build earliest import from history for THIS movie only recs = client.history_all_for_movie(movie_id) import_ts: Optional[int] = None for r in recs: et = str(r.get("eventType") or "").lower() if "import" in et: dt = to_epoch(r.get("date")) import_ts = min(import_ts, dt) if (import_ts and dt) else (dt or import_ts) # Preferred: import; fallback digital -> physical digital = to_epoch(movie.get("digitalRelease") or movie.get("digitalReleaseDate") or movie.get("inCinemas")) physical = to_epoch(movie.get("physicalRelease") or movie.get("physicalReleaseDate")) # persist to movie table for future runs db_upsert_movie(conn, movie_id, import_ts, digital, physical) if import_ts: return import_ts, "history" if digital: return digital, "digital" if physical: return physical, "physical" return None # ----------------------------- # Apply mtimes # ----------------------------- def apply_mtime(path: str, ts: int): try: os.utime(path, (ts, ts), follow_symlinks=False) except Exception: os.utime(path, (ts, ts)) # ----------------------------- # Commands # ----------------------------- def cmd_apply(args, env): db = db_open(args.db) kind = args.kind gotify_send(env, "Apply mtimes: start", f"kind={kind}") roots = resolve_roots(kind, args.root, env) if not roots: raise SystemExit("No valid roots. Set --root or TV_ROOTS/MOVIE_ROOTS in .env") files = list(iter_media_files(roots)) log(f"Found {len(files)} media file(s) under {', '.join(roots)}") # Lazy ARR setup sonarr_client = None radarr_client = None series_by_id: Dict[int, Dict[str, Any]] = {} series_by_imdb: Dict[str, int] = {} title_year_map: Dict[Tuple[str, Optional[int]], int] = {} episodes_cache: Dict[int, Dict[Tuple[int, int], Dict[str, Any]]] = {} earliest_import_cache: Dict[int, Dict[int, int]] = {} if args.live_on_miss: if kind == "tv": log("Initializing Sonarr (series index only)…") client, s_by_id, s_by_imdb, tmap = build_sonarr_index(env) sonarr_client = client series_by_id = s_by_id series_by_imdb = s_by_imdb title_year_map = tmap else: url = env_get(env, "RADARR_URL", "") key = env_get(env, "RADARR_API_KEY", "") if not url or not key: raise SystemExit("RADARR_URL/RADARR_API_KEY not set") timeout = env_get_int(env, "ARR_TIMEOUT", 60) radarr_client = Radarr(url, key, timeout=timeout) log("Initialized Radarr client (lazy per-movie lookups).") # Progress notify_every = env_get_int(env, "GOTIFY_TICK_EVERY", 500) last_notify = 0 done = skipped = updated = failures = 0 for idx, path in enumerate(files, 1): try: # cached file path? cached = db_get_file_ts(db, path) if cached and not args.reapply_all: skipped += 1 else: ts_src: Optional[Tuple[int, str]] = None if kind == "tv": if sonarr_client: ts_src = tv_preferred_ts_lazy( path, db, sonarr_client, series_by_id, series_by_imdb, title_year_map, episodes_cache, earliest_import_cache ) else: if radarr_client: ts_src = movie_preferred_ts_lazy(path, db, radarr_client) if ts_src: ts, src = ts_src db_upsert_file(db, path, ts, src) apply_mtime(path, ts) updated += 1 else: log(f"WARN: no import/release date for {path}") failures += 1 done += 1 if notify_every > 0 and done - last_notify >= notify_every: gotify_send(env, f"Apply {kind}: progress", f"{done}/{len(files)} files (updated {updated}, skipped {skipped}, failures {failures})") last_notify = done except Exception as e: failures += 1 log(f"WARN: failed on {path}: {e}") # traceback.print_exc() db.commit() gotify_send(env, f"Apply mtimes: done ({kind})", f"total={done} updated={updated} skipped={skipped} failures={failures}") log(f"Apply complete: total={done} updated={updated} skipped={skipped} failures={failures}") db.close() def cmd_build_cache(args, env): # Optional now; lazy apply fills DB as it goes. # Keeping a light build for people who still want a pre-warm step. kind = args.kind db = db_open(args.db) if kind == "tv": client, series_by_id, series_by_imdb, title_year_map = build_sonarr_index(env) wrote = 0 for sid in series_by_id.keys(): eps = client.episodes_for_series(sid) recs = client.history_all_for_series(sid) by_epid: Dict[int, int] = {} for r in recs: et = str(r.get("eventType") or "").lower() if "import" not in et: continue ep = r.get("episode") or {} eid = ep.get("id"); dt = to_epoch(r.get("date")) if eid and dt: cur = by_epid.get(eid) by_epid[eid] = min(cur, dt) if cur else dt for e in eps: season = int(e.get("seasonNumber", -1)) epno = int(e.get("episodeNumber", -1)) eid = e.get("id") air = to_epoch(e.get("airDateUtc") or e.get("airDate")) imp = by_epid.get(eid) db_upsert_episode(db, sid, season, epno, imp, air) wrote += 1 if wrote % 2000 == 0: db.commit() db.commit() log(f"Episode cache rows upserted: {wrote}") else: url = env_get(env, "RADARR_URL", "") key = env_get(env, "RADARR_API_KEY", "") if not url or not key: raise SystemExit("RADARR_URL/RADARR_API_KEY not set") timeout = env_get_int(env, "ARR_TIMEOUT", 60) rad = Radarr(url, key, timeout=timeout) log("Movie cache build (lazy is preferred; this just verifies connectivity).") gotify_send(env, "Cache build: done", f"kind={kind}") db.close() # ----------------------------- # CLI # ----------------------------- def main(): parser = argparse.ArgumentParser(description="Media date cache and mtime applier (lazy Sonarr/Radarr).") sub = parser.add_subparsers(dest="cmd", required=True) p_build = sub.add_parser("build-cache", help="(Optional) Prewarm caches") p_build.add_argument("--kind", choices=["tv", "movie"], required=True) p_build.add_argument("--db", required=True) p_apply = sub.add_parser("apply", help="Apply file mtimes; with --live-on-miss it lazily queries Sonarr/Radarr for misses") p_apply.add_argument("--kind", choices=["tv", "movie"], required=True) p_apply.add_argument("--db", required=True) p_apply.add_argument("--root", help="Root path; if omitted uses TV_ROOTS/MOVIE_ROOTS from .env") p_apply.add_argument("--live-on-miss", action="store_true") p_apply.add_argument("--reapply-all", action="store_true") args = parser.parse_args() env = load_env() if args.cmd == "build-cache": gotify_send(env, "Cache build: start", f"kind={args.kind}") cmd_build_cache(args, env) else: cmd_apply(args, env) if __name__ == "__main__": main()