#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Media Date Cache: - Caches and applies mtimes for TV episodes (Sonarr) and Movies (Radarr) - First preference: FIRST import date from *history* - Fallbacks: TV -> episode airDateUtc ; Movies -> digitalRelease or physicalRelease - Works from DB cache; with --live-on-miss it queries Sonarr/Radarr when missing, inserts, and applies immediately - Multi-root aware via .env (TV_ROOTS, MOVIE_ROOTS) and/or --root - Gotify progress notifications supported Only dependency: requests """ import os, re, sys, time, json, sqlite3, argparse, stat, traceback from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Tuple try: import requests except ImportError: print("ERROR: 'requests' is required. Activate your venv and run: pip install requests", file=sys.stderr) sys.exit(1) # ----------------------------- # Utilities / ENV # ----------------------------- ENV_DEFAULT_PATH = os.path.join(os.path.dirname(__file__), ".env") ISO_FMT = "%Y-%m-%dT%H:%M:%S%z" 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]: """Simple .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("#"): continue if "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip() # Remove inline comments (# ...) unless inside quotes 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: r = requests.post( url.rstrip("/") + "/message", data={"title": title, "message": message, "priority": prio}, headers={"X-Gotify-Key": token}, timeout=15, ) r.raise_for_status() except Exception as e: # Never crash on Gotify issues 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, -- history|airdate|digital|physical|manual updated_at INTEGER NOT NULL ); -- Optional per-episode cache (Sonarr) 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) ); -- Optional per-movie cache (Radarr) 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: sqlite3.Connection, series_id: int, season: int, episode: int, import_ts: Optional[int], airdate_ts: Optional[int]): 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: sqlite3.Connection, series_id: int, season: int, episode: int) -> Optional[Tuple[Optional[int], Optional[int]]]: 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: sqlite3.Connection, movie_id: int, import_ts: Optional[int], digital_ts: Optional[int], physical_ts: Optional[int]): 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: sqlite3.Connection, movie_id: int) -> Optional[Tuple[Optional[int], Optional[int], Optional[int]]]: cur = conn.execute("SELECT import_ts, digital_ts, physical_ts FROM movies WHERE movie_id=?", (movie_id,)) return cur.fetchone() # ----------------------------- # Sonarr / Radarr clients # ----------------------------- class BaseArr: def __init__(self, base: str, api_key: str): self.base = base.rstrip("/") self.session = requests.Session() self.session.headers.update({"X-Api-Key": api_key}) def _get(self, path: str, params: Dict[str, Any] = None): url = self.base + path r = self.session.get(url, params=params or {}, timeout=60) r.raise_for_status() try: return r.json() except Exception: return None class Sonarr(BaseArr): # Endpoints: v3 def all_series(self) -> List[Dict[str, Any]]: data = self._get("/api/v3/series") return data if isinstance(data, list) else [] def get_series(self, series_id: int) -> Optional[Dict[str, Any]]: data = self._get(f"/api/v3/series/{series_id}") return data if isinstance(data, dict) else None def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: # Returns list 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) -> Tuple[List[Dict[str, Any]], int]: 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) # Sonarr returns dict with records/totalRecords 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 # Fallback: maybe a raw list (rare) if isinstance(data, list): return data, len(data) return [], 0 class Radarr(BaseArr): def all_movies(self) -> List[Dict[str, Any]]: data = self._get("/api/v3/movie") return data if isinstance(data, list) else [] def history_page(self, page: int, page_size: int = 250, movie_id: Optional[int] = None) -> Tuple[List[Dict[str, Any]], int]: params = { "page": page, "pageSize": page_size, "sortDirection": "ascending", "includeMovie": "true", } if movie_id: params["movieId"] = movie_id data = self._get("/api/v3/history", params=params) if isinstance(data, dict): recs = data.get("records") or [] total = int(data.get("totalRecords") or 0) return recs, total if isinstance(data, list): return data, len(data) return [], 0 # ----------------------------- # 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(folder: str) -> Optional[str]: m = IMDB_RE.search(folder) return m.group(1).lower() if m else None def parse_series_season_episode(path: str) -> Optional[Tuple[int, List[int]]]: # Try filename first 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 parent directory (rare) parts = path.split(os.sep) for part in reversed(parts): 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: # e.g. /.../TV/tv/Friends (1994) [imdb-tt0108778]/Season 05/... # series folder is the directory directly under tv/tv6 parts = path.split(os.sep) # Find 'tv' or 'tv6' and take the next component for i, p in enumerate(parts): if p in ("tv", "tv6"): if i + 1 < len(parts): return parts[i + 1] # Fallback: take grandparent of the file return os.path.basename(os.path.dirname(os.path.dirname(path))) def extract_title_and_year(series_folder: str) -> Tuple[str, Optional[int]]: # "Friends (1994) [imdb-tt0108778]" -> ("Friends", 1994) year = None m = YEAR_PAREN_RE.search(series_folder) if m: try: year = int(m.group(1)) except Exception: year = None # title is everything before "(YYYY" if "(" in series_folder: title = series_folder.split("(")[0].strip() else: title = series_folder return title, year def to_epoch(ts_str: str) -> Optional[int]: if not ts_str: return None try: # Normalize to UTC if no timezone provided if ts_str.endswith("Z"): dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) else: # Sonarr/Radarr dates are iso8601 with Z; but just in case: dt = datetime.fromisoformat(ts_str if "+" in ts_str or ts_str.endswith("Z") else ts_str + "+00:00") return int(dt.timestamp()) except Exception: # Try a few loose formats 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 def earliest_valid(*vals: Optional[int]) -> Optional[int]: xs = [v for v in vals if isinstance(v, int) and v > 0] return min(xs) if xs else None # ----------------------------- # Root resolution # ----------------------------- def split_paths(s: str) -> List[str]: # split on ':' or ',' and keep existing directories only 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 kind == "tv": if root_arg and os.path.isdir(root_arg): roots.append(root_arg) tv_env = env_get(env, "TV_ROOTS", "") if tv_env: roots.extend(split_paths(tv_env)) # If root points to /Media/TV, include known subfolders if present 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) elif kind == "movie": if root_arg and os.path.isdir(root_arg): roots.append(root_arg) 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) # Dedup while preserving order: seen = set() dedup = [] for p in roots: if p not in seen: seen.add(p) dedup.append(p) return dedup or ([root_arg] if root_arg else []) def iter_media_files(roots: List[str], kind: 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) # ----------------------------- # Build caches from Sonarr/Radarr # ----------------------------- def build_sonarr_maps(env: Dict[str, str]) -> Tuple[Sonarr, Dict[int, Dict[str, Any]], Dict[str, int], Dict[Tuple[int, int, int], int], Dict[int, Dict[Tuple[int, int], Dict[str, Any]]]]: """ Returns: client, series_by_id, series_by_imdb (imdbId -> seriesId), earliest_import_by_episode_id, episodes_map_by_series (seriesId -> {(season,episode): episode_obj}) """ 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 in environment or .env") client = Sonarr(url, key) series_list = client.all_series() series_by_id: Dict[int, Dict[str, Any]] = {} series_by_imdb: Dict[str, int] = {} for s in series_list: sid = int(s.get("id")) series_by_id[sid] = s imdb = (s.get("imdbId") or "").lower().strip() if imdb: series_by_imdb[imdb] = sid # Episodes per series episodes_map_by_series: Dict[int, Dict[Tuple[int, int], Dict[str, Any]]] = {} for sid in series_by_id.keys(): eps = client.episodes_for_series(sid) m: Dict[Tuple[int, int], Dict[str, Any]] = {} for e in eps: key = (int(e.get("seasonNumber", -1)), int(e.get("episodeNumber", -1))) m[key] = e episodes_map_by_series[sid] = m # Build earliest import per episode via paged history earliest_import_by_episode_id: Dict[Tuple[int], int] = {} page = 1 page_size = 250 total = None while True: recs, tot = client.history_page(page, page_size) if total is None: total = tot if not recs: break 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 = earliest_import_by_episode_id.get(eid) earliest_import_by_episode_id[eid] = min(cur, date) if cur else date if len(recs) < page_size: break page += 1 return client, series_by_id, series_by_imdb, earliest_import_by_episode_id, episodes_map_by_series def build_radarr_maps(env: Dict[str, str]) -> Tuple[Radarr, Dict[int, Dict[str, Any]], Dict[str, int], Dict[int, int], Dict[int, Tuple[Optional[int], Optional[int]]]]: """ Returns: client, movies_by_id, movies_by_imdb (imdbId -> movieId), earliest_import_by_movie_id, releases_by_movie_id (digital_ts, physical_ts) """ url = env_get(env, "RADARR_URL", "") key = env_get(env, "RADARR_API_KEY", "") if not url or not key: raise RuntimeError("RADARR_URL/RADARR_API_KEY not set in environment or .env") client = Radarr(url, key) movies = client.all_movies() movies_by_id: Dict[int, Dict[str, Any]] = {} movies_by_imdb: Dict[str, int] = {} releases_by_movie_id: Dict[int, Tuple[Optional[int], Optional[int]]] = {} for m in movies: mid = int(m.get("id")) movies_by_id[mid] = m imdb = (m.get("imdbId") or "").lower().strip() if imdb: movies_by_imdb[imdb] = mid # known release fields digital = to_epoch(m.get("digitalRelease") or m.get("digitalReleaseDate") or m.get("inCinemas")) physical = to_epoch(m.get("physicalRelease") or m.get("physicalReleaseDate")) releases_by_movie_id[mid] = (digital, physical) # History earliest import by movie earliest_by_movie: Dict[int, int] = {} page = 1 page_size = 250 total = None while True: recs, tot = client.history_page(page, page_size) if total is None: total = tot if not recs: break for r in recs: et = str(r.get("eventType") or "").lower() if "import" not in et: continue movie = r.get("movie") or {} mid = movie.get("id") date = to_epoch(r.get("date")) if mid and date: cur = earliest_by_movie.get(mid) earliest_by_movie[mid] = min(cur, date) if cur else date if len(recs) < page_size: break page += 1 return client, movies_by_id, movies_by_imdb, earliest_by_movie, releases_by_movie_id # ----------------------------- # Core: get preferred ts per file # ----------------------------- def tv_preferred_ts_for_file( path: str, conn: sqlite3.Connection, sonarr_pack: Tuple[Sonarr, Dict[int, Dict[str, Any]], Dict[str, int], Dict[int, int], Dict[int, Dict[Tuple[int, int], Dict[str, Any]]]], live_insert_db: bool = True, ) -> Optional[Tuple[int, str]]: client, series_by_id, series_by_imdb, earliest_import_by_episode_id, episodes_map_by_series = sonarr_pack series_folder = extract_series_folder(path) imdb = parse_imdb(series_folder) title, year = extract_title_and_year(series_folder) # Get seriesId series_id = None if imdb and imdb in series_by_imdb: series_id = series_by_imdb[imdb] else: # fallback: match by (title, year) cand = [] tnorm = title.strip().lower() for sid, s in series_by_id.items(): stitle = str(s.get("title") or "").strip().lower() syear = s.get("year") if stitle == tnorm and (year is None or syear == year): cand.append(sid) if cand: series_id = cand[0] if not series_id: return None # Parse SxxEyy se = parse_series_season_episode(path) if not se: return None season, eps = se # Ensure we have episode map emap = episodes_map_by_series.get(series_id, {}) # Find best ts among target episodes import_ts_list: List[int] = [] air_ts_list: List[int] = [] for e in eps: epi = emap.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 in earliest_import_by_episode_id: import_ts_list.append(earliest_import_by_episode_id[eid]) # write into episodes cache table if enabled if live_insert_db: db_upsert_episode(conn, series_id, season, e, earliest_import_by_episode_id.get(eid), air_ts) # Preference: earliest import; else earliest airdate imp = min(import_ts_list) if import_ts_list else None air = min(air_ts_list) if air_ts_list else None if imp: return imp, "history" if air: return air, "airdate" return None def movie_preferred_ts_for_file( path: str, conn: sqlite3.Connection, radarr_pack: Tuple[Radarr, Dict[int, Dict[str, Any]], Dict[str, int], Dict[int, int], Dict[int, Tuple[Optional[int], Optional[int]]]], live_insert_db: bool = True, ) -> Optional[Tuple[int, str]]: client, movies_by_id, movies_by_imdb, earliest_by_movie, releases_by_movie_id = radarr_pack # Movie folder right under movies/movies6 parts = path.split(os.sep) movie_folder = "" for i, p in enumerate(parts): if p in ("movies", "movies6"): if 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) # Map to movie_id movie_id = None if imdb and imdb in movies_by_imdb: movie_id = movies_by_imdb[imdb] else: # title/year fallback tnorm = title.strip().lower() cand = [] for mid, m in movies_by_id.items(): mtitle = str(m.get("title") or "").strip().lower() myear = m.get("year") if mtitle == tnorm and (year is None or myear == year): cand.append(mid) if cand: movie_id = cand[0] if not movie_id: return None imp = earliest_by_movie.get(movie_id) digital, physical = releases_by_movie_id.get(movie_id, (None, None)) # Preference: earliest import; fallbacks: digital -> physical if live_insert_db: db_upsert_movie(conn, movie_id, imp, digital, physical) if imp: return imp, "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: # Try with follow_symlinks default if FS complains os.utime(path, (ts, ts)) # ----------------------------- # Commands # ----------------------------- def cmd_build_cache(args, env): kind = args.kind db = db_open(args.db) if kind == "tv": log("Refreshing Sonarr caches (series, episodes, history earliest-import)…") sonarr_pack = build_sonarr_maps(env) # Optionally write episodes into DB (import & airdate) conn = db _, series_by_id, _, earliest_imp_by_eid, episodes_map_by_series = sonarr_pack wrote = 0 for sid, epmap in episodes_map_by_series.items(): for (season, epnum), epi in epmap.items(): eid = epi.get("id") air = to_epoch(epi.get("airDateUtc") or epi.get("airDate")) imp = earliest_imp_by_eid.get(eid) db_upsert_episode(conn, sid, season, epnum, imp, air) wrote += 1 if wrote % 1000 == 0: conn.commit() conn.commit() log(f"Episode cache rows upserted: {wrote}") elif kind == "movie": log("Refreshing Radarr caches (movies, history earliest-import)…") radarr_pack = build_radarr_maps(env) conn = db _, movies_by_id, _, earliest_by_movie, releases = radarr_pack wrote = 0 for mid in movies_by_id.keys(): imp = earliest_by_movie.get(mid) dig, phy = releases.get(mid, (None, None)) db_upsert_movie(conn, mid, imp, dig, phy) wrote += 1 if wrote % 1000 == 0: conn.commit() conn.commit() log(f"Movie cache rows upserted: {wrote}") else: raise SystemExit("--kind must be tv or movie") # Reports (optional) if args.report_json or args.report_csv: export_report(db, kind, args.report_json, args.report_csv) gotify_send(env, "Cache build: done", f"kind={kind}") db.close() def export_report(conn: sqlite3.Connection, kind: str, json_path: Optional[str], csv_path: Optional[str]): if kind == "tv": rows = conn.execute("SELECT series_id, season, episode, import_ts, airdate_ts FROM episodes").fetchall() records = [ {"series_id": r[0], "season": r[1], "episode": r[2], "import_ts": r[3], "airdate_ts": r[4]} for r in rows ] else: rows = conn.execute("SELECT movie_id, import_ts, digital_ts, physical_ts FROM movies").fetchall() records = [ {"movie_id": r[0], "import_ts": r[1], "digital_ts": r[2], "physical_ts": r[3]} for r in rows ] if json_path: os.makedirs(os.path.dirname(json_path), exist_ok=True) with open(json_path, "w", encoding="utf-8") as f: json.dump(records, f, indent=2) if csv_path: os.makedirs(os.path.dirname(csv_path), exist_ok=True) import csv with open(csv_path, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=list(records[0].keys()) if records else []) if records: w.writeheader() w.writerows(records) def cmd_apply(args, env): kind = args.kind db = db_open(args.db) gotify_send(env, "Apply mtimes: start", f"kind={kind}") roots = resolve_roots(kind, args.root, env) if not roots: raise SystemExit("No valid roots found. Set --root or TV_ROOTS/MOVIE_ROOTS in .env") # If live-on-miss, build in-memory maps once to avoid hammering endpoints sonarr_pack = radarr_pack = None if args.live_on_miss: if kind == "tv": log("Cache miss detected; refreshing Sonarr data live once…") sonarr_pack = build_sonarr_maps(env) else: log("Cache miss detected; refreshing Radarr data live once…") radarr_pack = build_radarr_maps(env) # Scan files = list(iter_media_files(roots, kind)) log(f"Found {len(files)} media file(s) under {', '.join(roots)}") # Ticker notify_every = env_get_int(env, "GOTIFY_TICK_EVERY", 500) # files last_notify = 0 done = 0 updated = 0 skipped = 0 failures = 0 for idx, path in enumerate(files, 1): relog = False try: # Already cached by path? cached = db_get_file_ts(db, path) if cached and not args.reapply_all: skipped += 1 continue # Compute preferred ts (from cache tables if present; or live-on-miss) ts_src = None if kind == "tv": ts_src = None # If we already built episode cache and path is not in files yet: try live maps for this file if sonarr_pack: got = tv_preferred_ts_for_file(path, db, sonarr_pack, live_insert_db=True) if got: ts, src = got db_upsert_file(db, path, ts, src) ts_src = (ts, src) if not ts_src: # Ultimate fallback: none pass else: # movie ts_src = None if radarr_pack: got = movie_preferred_ts_for_file(path, db, radarr_pack, live_insert_db=True) if got: ts, src = got db_upsert_file(db, path, ts, src) ts_src = (ts, src) if not ts_src: # If we couldn't determine a date at all log(f"WARN: no import/release date for {path}") failures += 1 else: ts, src = ts_src apply_mtime(path, ts) updated += 1 except Exception as e: failures += 1 log(f"WARN: failed on {path}: {e}") # Uncomment to debug deeply # traceback.print_exc() 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 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() # ----------------------------- # CLI # ----------------------------- def main(): parser = argparse.ArgumentParser(description="Media date cache and mtime applier (Sonarr/Radarr).") sub = parser.add_subparsers(dest="cmd", required=True) # build-cache p_build = sub.add_parser("build-cache", help="Build caches from Sonarr/Radarr into SQLite") p_build.add_argument("--kind", choices=["tv", "movie"], required=True) p_build.add_argument("--db", required=True) p_build.add_argument("--report-json") p_build.add_argument("--report-csv") # apply p_apply = sub.add_parser("apply", help="Apply file mtimes using DB cache; with --live-on-miss it will query 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 (supports multi-roots)") p_apply.add_argument("--live-on-miss", action="store_true", help="Query Sonarr/Radarr at runtime for files missing in DB and cache them") p_apply.add_argument("--reapply-all", action="store_true", help="Ignore existing per-file cache and reapply") 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) elif args.cmd == "apply": cmd_apply(args, env) if __name__ == "__main__": main()