#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import argparse import dataclasses from dataclasses import field import datetime as dt import json import os import re import sqlite3 import sys import time from pathlib import Path from typing import Dict, Iterator, List, Optional, Tuple import urllib.request import urllib.error # ================= # Basics / constants # ================= DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "") DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "") SONARR_TIMEOUT = int(os.getenv("SONARR_TIMEOUT", "45")) SONARR_RETRIES = int(os.getenv("SONARR_RETRIES", "3")) RADARR_TIMEOUT = int(os.getenv("RADARR_TIMEOUT", "45")) RADARR_RETRIES = int(os.getenv("RADARR_RETRIES", "3")) TMDB_TIMEOUT = int(os.getenv("TMDB_TIMEOUT", "45")) TMDB_RETRIES = int(os.getenv("TMDB_RETRIES", "3")) LOG_TS_FMT = "%Y-%m-%d %H:%M:%S" VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm"} SIDECAR_EXTS = {".srt", ".sub", ".idx", ".ass", ".ssa", ".vtt", ".smi", ".rt", ".txt"} NFO_SIMPLE_PATTERN = re.compile(r"^S\d{2}E\d{2}\.nfo$", re.IGNORECASE) SE_EP_RX = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})") IMDB_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) TMDB_RX = re.compile(r"\[tmdb-(\d+)\]", re.IGNORECASE) YEAR_RX = re.compile(r"\((\d{4})\)") DEBUG_HTTP = False DEBUG_TRUNCATE = 3000 # chars def log(msg: str) -> None: ts = dt.datetime.now().strftime(LOG_TS_FMT) print(f"[{ts}] {msg}", flush=True) def dlog(msg: str) -> None: if DEBUG_HTTP: log(f"[DEBUG] {msg}") def send_gotify_notification(title: str, message: str, priority: int = 5) -> None: if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN: return try: url = f"{DEFAULT_GOTIFY_URL.rstrip('/')}/message" data = json.dumps({"title": title, "message": message, "priority": priority}).encode("utf-8") req = urllib.request.Request(url, data=data, method="POST") req.add_header("Content-Type", "application/json") req.add_header("X-Gotify-Key", DEFAULT_GOTIFY_TOKEN) with urllib.request.urlopen(req, timeout=15): pass except Exception as e: log(f"WARN: Gotify notification failed: {e}") def load_env_file(env_path: Optional[str]) -> None: if not env_path: return p = Path(env_path) if not p.exists(): log(f"WARN: .env not found: {env_path}") return for line in p.read_text(encoding="utf-8", errors="ignore").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip().strip('"').strip("'") os.environ[k] = v # ================= # DB # ================= def db_init(cx: sqlite3.Connection) -> None: cx.execute( """ 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 TEXT, last_updated INTEGER DEFAULT (strftime('%s', 'now')) ) """ ) cx.commit() def db_get_cached(cx: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]: cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,)) row = cur.fetchone() return (int(row[0]), row[1]) if row else None def db_upsert(cx: sqlite3.Connection, path: str, kind: str, chosen_ts: int, chosen_src: str, extra: Dict[str, object] | None) -> None: cx.execute( """ INSERT INTO media_dates(path, kind, chosen_ts, chosen_src, extra, last_updated) VALUES(?,?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET kind=excluded.kind, chosen_ts=excluded.chosen_ts, chosen_src=excluded.chosen_src, extra=excluded.extra, last_updated=excluded.last_updated """, (path, kind, int(chosen_ts), chosen_src, json.dumps(extra or {}, ensure_ascii=False), int(time.time())), ) cx.commit() # ================= # HTTP helper # ================= def _pretty(obj) -> str: try: return json.dumps(obj, indent=2, ensure_ascii=False)[:DEBUG_TRUNCATE] except Exception: return str(obj)[:DEBUG_TRUNCATE] def _req_json( method: str, url: str, *, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, object]] = None, timeout: int = 30, retries: int = 2 ) -> dict | list: from urllib.parse import urlencode full_url = f"{url}?{urlencode(params)}" if params else url last_err = None for attempt in range(max(1, retries)): try: dlog(f"HTTP {method} {full_url}") req = urllib.request.Request(full_url, method=method) for k, v in (headers or {}).items(): req.add_header(k, v) with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read() txt = body.decode("utf-8", errors="ignore") try: js = json.loads(txt) dlog(f"← {full_url} OK\n{_pretty(js)}") return js except Exception as je: dlog(f"← {full_url} NON-JSON ({len(txt)} bytes)\n{txt[:DEBUG_TRUNCATE]}") raise RuntimeError(f"Non-JSON response from {full_url}") from je except urllib.error.HTTPError as e: last_err = e err_body = e.read().decode("utf-8", errors="ignore") dlog(f"← {full_url} HTTP {e.code}\n{err_body[:DEBUG_TRUNCATE]}") time.sleep(0.35) except Exception as e: last_err = e dlog(f"← {full_url} ERROR: {e}") time.sleep(0.35) raise last_err or RuntimeError("request failed") # ================= # API clients + per-series cache # ================= @dataclasses.dataclass class SonarrClient: url: str api_key: str timeout: int = SONARR_TIMEOUT retries: int = SONARR_RETRIES def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} def ping(self) -> bool: try: _req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries) return True except Exception as e: log(f"WARN: Sonarr ping failed: {e}"); return False def series_index(self) -> List[dict]: res = _req_json("GET", f"{self.url}/api/v3/series", headers=self._h(), timeout=self.timeout, retries=self.retries) return res if isinstance(res, list) else list(res or []) def history_for(self, series_id: int) -> List[dict]: res = _req_json( "GET", f"{self.url}/api/v3/history/series", headers=self._h(), params={"seriesId": series_id, "pageSize": 1000}, timeout=self.timeout, retries=self.retries ) if isinstance(res, dict): recs = res.get("records") return recs if isinstance(recs, list) else [] if isinstance(res, list): return res return [] def episodes_for(self, series_id: int) -> List[dict]: res = _req_json("GET", f"{self.url}/api/v3/episode", headers=self._h(), params={"seriesId": series_id}, timeout=self.timeout, retries=self.retries) return res if isinstance(res, list) else [] @dataclasses.dataclass class SonarrCache: sc: SonarrClient episodes_map: Dict[int, List[dict]] = field(default_factory=dict) history_map: Dict[int, List[dict]] = field(default_factory=dict) def episodes(self, series_id: int) -> List[dict]: if series_id not in self.episodes_map: eps = self.sc.episodes_for(series_id) self.episodes_map[series_id] = eps dlog(f"Cached Sonarr episodes for series {series_id}: {len(eps)}") return self.episodes_map[series_id] def history(self, series_id: int) -> List[dict]: if series_id not in self.history_map: hist = self.sc.history_for(series_id) self.history_map[series_id] = hist dlog(f"Cached Sonarr history for series {series_id}: {len(hist)}") return self.history_map[series_id] @dataclasses.dataclass class RadarrClient: url: str api_key: str timeout: int = RADARR_TIMEOUT retries: int = RADARR_RETRIES def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key} def ping(self) -> bool: try: _req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries) return True except Exception as e: log(f"WARN: Radarr ping failed: {e}"); return False def movies_index(self) -> List[dict]: res = _req_json("GET", f"{self.url}/api/v3/movie", headers=self._h(), timeout=self.timeout, retries=self.retries) return res if isinstance(res, list) else [] def history_for(self, movie_id: int) -> List[dict]: res = _req_json( "GET", f"{self.url}/api/v3/history/movie", headers=self._h(), params={"movieId": movie_id, "pageSize": 1000}, timeout=self.timeout, retries=self.retries ) if isinstance(res, dict): recs = res.get("records") return recs if isinstance(recs, list) else [] if isinstance(res, list): return res return [] @dataclasses.dataclass class TMDBClient: api_key: str primary_country: str = "US" base_url: str = "https://api.themoviedb.org/3" timeout: int = TMDB_TIMEOUT retries: int = TMDB_RETRIES def _p(self, **kw) -> Dict[str, object]: p = {"api_key": self.api_key, "language": "en-US"}; p.update(kw); return p def find_tv_by_imdb(self, imdb_id: str) -> Optional[int]: js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries) lst = js.get("tv_results") if isinstance(js, dict) else None return lst[0].get("id") if lst else None def search_tv(self, name: str, year: Optional[int]) -> Optional[int]: js = _req_json("GET", f"{self.base_url}/search/tv", params=self._p(query=name, first_air_date_year=year or ""), timeout=self.timeout, retries=self.retries) res = js.get("results") if isinstance(js, dict) else None return res[0].get("id") if res else None def find_movie_by_imdb(self, imdb_id: str) -> Optional[int]: js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries) lst = js.get("movie_results") if isinstance(js, dict) else None return lst[0].get("id") if lst else None def search_movie(self, name: str, year: Optional[int]) -> Optional[int]: js = _req_json("GET", f"{self.base_url}/search/movie", params=self._p(query=name, year=year or "", primary_release_year=year or ""), timeout=self.timeout, retries=self.retries) res = js.get("results") if isinstance(js, dict) else None return res[0].get("id") if res else None def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]: try: js = _req_json("GET", f"{self.base_url}/tv/{tv_id}/season/{season}/episode/{episode}", params=self._p(), timeout=self.timeout, retries=self.retries) return js.get("air_date") if isinstance(js, dict) else None except Exception: return None def movie_release_date(self, movie_id: int) -> Optional[str]: try: js = _req_json("GET", f"{self.base_url}/movie/{movie_id}", params=self._p(), timeout=self.timeout, retries=self.retries) return js.get("release_date") if isinstance(js, dict) else None except Exception: return None # ================= # Parsers # ================= @dataclasses.dataclass class EpisodeKey: series_title: str season: int episode: int imdb_id: Optional[str] tmdb_id: Optional[str] year: Optional[int] def parse_episode_from_path(p: Path) -> Optional[EpisodeKey]: parent = p.parent show_dir = parent.parent.name if parent.name.lower().startswith("season ") else parent.name m = SE_EP_RX.search(p.name) if not m: return None season = int(m.group(1)); episode = int(m.group(2)) imdb_id = (IMDB_RX.search(p.name) or IMDB_RX.search(show_dir) or [None, None])[1] tmdb_id = (TMDB_RX.search(p.name) or TMDB_RX.search(show_dir) or [None, None])[1] year = None m3 = YEAR_RX.search(show_dir) if m3: try: year = int(m3.group(1)) except: year = None series_title = IMDB_RX.sub("", show_dir) series_title = TMDB_RX.sub("", series_title) series_title = YEAR_RX.sub("", series_title).strip() or show_dir return EpisodeKey(series_title, season, episode, imdb_id, tmdb_id, year) @dataclasses.dataclass class MovieKey: title: str year: Optional[int] imdb_id: Optional[str] tmdb_id: Optional[str] def parse_movie_from_path(p: Path) -> Optional[MovieKey]: movie_dir = p.parent.name imdb_id = (IMDB_RX.search(movie_dir) or [None, None])[1] tmdb_id = (TMDB_RX.search(movie_dir) or [None, None])[1] year = None m3 = YEAR_RX.search(movie_dir) if m3: try: year = int(m3.group(1)) except: year = None clean = IMDB_RX.sub("", movie_dir); clean = TMDB_RX.sub("", clean); clean = YEAR_RX.sub("", clean).strip() or movie_dir return MovieKey(clean, year, imdb_id, tmdb_id) # ================= # Picking dates (cached lists) # ================= def parse_date_yyyy_mm_dd(s: str) -> Optional[int]: try: y, m, d = map(int, s.split("-")) return int(dt.datetime(y, m, d, 0, 0, 0, tzinfo=dt.timezone.utc).timestamp()) except Exception: return None def pick_from_sonarr_import_for_episode_from_history(history: List[dict], episode_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: import_types = {"episodeFileImported", "downloadFolderImported", "manualImport"} grabbed_types = {"grabbed"} imports: List[Tuple[int, dict]] = [] grabs: List[Tuple[int, dict]] = [] for rec in history or []: if not isinstance(rec, dict): continue if int(rec.get("episodeId", -1)) != int(episode_id): continue et = rec.get("eventType") or "" date_s = rec.get("date") if not date_s: continue try: t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) ts = int(t.timestamp()) except Exception: continue if et in import_types: imports.append((ts, rec)) elif et in grabbed_types: grabs.append((ts, rec)) if imports: ts, rec = sorted(imports, key=lambda x: x[0])[0] return ts, "sonarr_import", {"episodeId": episode_id, "eventType": rec.get("eventType")} if grabs: ts, rec = sorted(grabs, key=lambda x: x[0])[0] return ts, "sonarr_grabbed", {"episodeId": episode_id, "eventType": rec.get("eventType")} return None def pick_from_sonarr_episode_from_list(episodes: List[dict], season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]: for ep in episodes or []: if not isinstance(ep, dict): continue if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode: air = ep.get("airDateUtc") or ep.get("airDate") if not air: return None if "T" in air: try: t = dt.datetime.fromisoformat(air.replace("Z", "+00:00")) return int(t.timestamp()), "sonarr_airdate", {"episodeId": ep.get("id")} except Exception: return None ts = parse_date_yyyy_mm_dd(air) if ts is not None: return ts, "sonarr_airdate", {"episodeId": ep.get("id")} return None return None def pick_from_radarr_history(rc: RadarrClient, movie_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: try: hist = rc.history_for(movie_id) except Exception as e: log(f"WARN: Radarr history timeout/err movie={movie_id}: {e}"); return None best_ts = None; best = None for rec in hist or []: if not isinstance(rec, dict): continue date_s = rec.get("date") or "" if not date_s: continue try: t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00")) ts = int(t.timestamp()) except Exception: continue if best_ts is None or ts < best_ts: best_ts = ts; best = rec if best_ts is not None: return best_ts, "radarr_history", {"history_id": (best or {}).get("id")} return None # TMDB fallback (unchanged) @dataclasses.dataclass class TMDBMovieLike: title: str; year: Optional[int]; imdb_id: Optional[str]; tmdb_id: Optional[str] def pick_from_tmdb_movie(tmdb: TMDBClient, mk: TMDBMovieLike) -> Optional[Tuple[int, str, Dict[str, object]]]: if not tmdb.api_key: return None movie_id = None if mk.tmdb_id: try: movie_id = int(mk.tmdb_id) except: movie_id = None if movie_id is None and mk.imdb_id: try: movie_id = tmdb.find_movie_by_imdb(mk.imdb_id) except Exception as e: log(f"WARN: TMDB find movie by IMDb failed {mk.imdb_id}: {e}") if movie_id is None: try: movie_id = tmdb.search_movie(mk.title, mk.year) except Exception as e: log(f"WARN: TMDB movie search failed title={mk.title}: {e}") if movie_id is None: return None rel = tmdb.movie_release_date(movie_id) if rel: ts = parse_date_yyyy_mm_dd(rel) if ts is not None: return ts, "tmdb_movie_release", {"tmdb_movie_id": movie_id, "release_date": rel} return None def pick_from_tmdb_tv(tmdb: TMDBClient, series_title: str, season: int, episode: int, imdb_id: Optional[str], tmdb_id: Optional[str], year: Optional[int]) -> Optional[Tuple[int, str, Dict[str, object]]]: if not tmdb.api_key: return None tv_id = None if tmdb_id: try: tv_id = int(tmdb_id) except: tv_id = None if tv_id is None and imdb_id: try: tv_id = tmdb.find_tv_by_imdb(imdb_id) except Exception as e: log(f"WARN: TMDB find by IMDb failed {imdb_id}: {e}") if tv_id is None: try: tv_id = tmdb.search_tv(series_title, year) except Exception as e: log(f"WARN: TMDB search failed title={series_title}: {e}") if tv_id is None: return None air = tmdb.episode_air_date(tv_id, season, episode) if air: ts = parse_date_yyyy_mm_dd(air) if ts is not None: return ts, "tmdb_episode_airdate", {"tmdb_tv_id": tv_id, "air_date": air} return None # ================= # NFO helpers # ================= def create_simple_nfo(season: int, episode: int, air_date: Optional[str], date_added: Optional[str]) -> str: aired_tag = f" {air_date}\n" if air_date else "" dateadded_tag = f" {date_added}\n" if date_added else "" return f""" {aired_tag}{dateadded_tag}""" def create_movie_nfo(title: str, year: Optional[int] = None, release_date: Optional[str] = None, date_added: Optional[str] = None) -> str: premiered_tag = f" {release_date}\n" if release_date else "" dateadded_tag = f" {date_added}\n" if date_added else "" year_tag = f" {year}\n" if year else "" title_tag = f" {title}\n" if title else "" return f""" {title_tag}{year_tag}{premiered_tag}{dateadded_tag}""" def _set_file_ts(path: Path, ts: int, dry_run: bool) -> bool: if dry_run: return True try: os.utime(str(path), (ts, ts)); return True except Exception as e: log(f"WARN: Failed to set timestamp on {path}: {e}"); return False def manage_nfo_files( media_dir: Path, kind: str, season: int = 0, episode: int = 0, air_date: Optional[str] = None, date_added: Optional[str] = None, title: Optional[str] = None, year: Optional[int] = None, release_date: Optional[str] = None, nfo_timestamp: Optional[int] = None, dry_run: bool = False ) -> Tuple[int, int, int]: created = 0; removed = 0; synced = 0 if kind == "tv": simple_nfo_name = f"S{season:02d}E{episode:02d}.nfo" simple_nfo_path = media_dir / simple_nfo_name nfo_content = create_simple_nfo(season, episode, air_date, date_added) try: should_write = True if simple_nfo_path.exists(): existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore') should_write = existing_content.strip() != nfo_content.strip() if should_write and not dry_run: simple_nfo_path.write_text(nfo_content, encoding='utf-8'); created = 1 elif should_write and dry_run: created = 1 except Exception as e: log(f"WARN: Failed to create/update {simple_nfo_path}: {e}") if nfo_timestamp is not None: if _set_file_ts(simple_nfo_path, nfo_timestamp, dry_run): synced += 1 for nfo_file in media_dir.glob("*.nfo"): n = nfo_file.name.lower() if NFO_SIMPLE_PATTERN.match(nfo_file.name): continue if n in ("season.nfo", "tvshow.nfo"): continue if not dry_run: try: nfo_file.unlink(); removed += 1; log(f"Removed complex NFO: {nfo_file.name}") except Exception as e: log(f"WARN: Failed to remove {nfo_file}: {e}") else: removed += 1; log(f"Would remove complex NFO: {nfo_file.name}") else: movie_nfo_path = media_dir / "movie.nfo" nfo_content = create_movie_nfo(title or "", year=year, release_date=release_date, date_added=date_added) try: should_write = True if movie_nfo_path.exists(): existing_content = movie_nfo_path.read_text(encoding='utf-8', errors='ignore') should_write = existing_content.strip() != nfo_content.strip() if should_write and not dry_run: movie_nfo_path.write_text(nfo_content, encoding='utf-8'); created = 1 elif should_write and dry_run: created = 1 except Exception as e: log(f"WARN: Failed to create/update {movie_nfo_path}: {e}") if nfo_timestamp is not None: if _set_file_ts(movie_nfo_path, nfo_timestamp, dry_run): synced += 1 return created, removed, synced # ================= # Sidecars # ================= def _season_episode_token(name: str) -> Optional[Tuple[int,int]]: m = SE_EP_RX.search(name) if not m: return None return int(m.group(1)), int(m.group(2)) def find_sidecar_files(media_file: Path) -> List[Path]: sidecars: List[Path] = [] parent_dir = media_file.parent base_stem = media_file.stem token = _season_episode_token(media_file.name) for f in parent_dir.iterdir(): if not f.is_file(): continue if f == media_file: continue suf = f.suffix.lower() if suf not in SIDECAR_EXTS: continue if f.stem == base_stem or f.name.startswith(base_stem + "."): sidecars.append(f); continue if token: other = _season_episode_token(f.name) if other and other == token: sidecars.append(f); continue uniq: Dict[str, Path] = {} for s in sidecars: uniq[s.name] = s return list(uniq.values()) def apply_timestamp_to_file(file_path: Path, timestamp: int, dry_run: bool = False) -> bool: if dry_run: return True try: os.utime(str(file_path), (timestamp, timestamp)); return True except Exception as e: log(f"WARN: Failed to set timestamp on {file_path}: {e}"); return False def iter_media_files(roots: List[str]) -> Iterator[Path]: for root in roots: base = Path(root) if not base.exists(): continue for p in base.rglob("*"): if p.is_file() and p.suffix.lower() in VIDEO_EXTS: yield p # ================= # Sonarr series match # ================= def choose_series_id_for_path(series_index: List[dict], fp: Path) -> Optional[int]: if not series_index: return None try: series_dir = fp.parent.parent.name if fp.parent.name.lower().startswith("season ") else fp.parent.name except Exception: series_dir = fp.parent.name base = series_dir.lower().strip() base = YEAR_RX.sub("", base); base = IMDB_RX.sub("", base); base = TMDB_RX.sub("", base); base = base.strip() for s in series_index: title = (s.get("title") or "").lower().strip() if base == title: return s.get("id") return None # ================= # Main apply # ================= def apply_command(args: argparse.Namespace) -> None: global DEBUG_HTTP DEBUG_HTTP = bool(args.debug_http) log("=== Media Date Sync Starting ===") load_env_file(args.env) sonarr_url = os.getenv("SONARR_URL", "http://sonarr:8989") sonarr_key = os.getenv("SONARR_API_KEY", "") radarr_url = os.getenv("RADARR_URL", "http://radarr:7878") radarr_key = os.getenv("RADARR_API_KEY", "") tmdb_key = os.getenv("TMDB_API_KEY", "") tmdb_country = os.getenv("TMDB_PRIMARY_COUNTRY", "US") log(f"SONARR_URL: {sonarr_url}") log(f"SONARR_API_KEY: {'SET' if sonarr_key else 'MISSING'}") log(f"TMDB_API_KEY: {'SET' if tmdb_key else 'MISSING'}") sc = None scache = None if sonarr_key and sonarr_url: sc = SonarrClient(sonarr_url, sonarr_key) if not sc.ping(): log("WARN: Sonarr not reachable"); sc = None else: log("Sonarr connection successful") scache = SonarrCache(sc) else: log("WARN: Sonarr not configured") tmdb = TMDBClient(tmdb_key, tmdb_country) if not tmdb_key: log("WARN: TMDB not configured - limited functionality") cx = sqlite3.connect(args.db) db_init(cx) log(f"Database initialized: {args.db}") series_index: List[dict] = [] if args.kind == "tv" and sc: try: log("Loading Sonarr series index...") series_index = sc.series_index() log(f"Loaded {len(series_index)} series from Sonarr") except Exception as e: log(f"WARN: Failed to load Sonarr series: {e}") roots = args.root or [] only_file = None if args.path: p = Path(args.path) if p.is_file(): roots = [str(p.parent)] only_file = p else: roots = [str(p)] if not roots: env_roots = os.getenv("TV_ROOTS" if args.kind == "tv" else "MOVIE_ROOTS", "") if env_roots: roots = [r for r in env_roots.split(":") if r.strip()] log(f"Using {'TV' if args.kind=='tv' else 'MOVIE'}_ROOTS from env: {', '.join(roots)}") if not roots: log("ERROR: No root paths specified. Use --root or set TV_ROOTS/MOVIE_ROOTS in .env"); return log(f"Scanning for media files in: {', '.join(roots)}") files = [only_file] if (only_file is not None) else list(iter_media_files(roots)) log(f"Found {len(files)} media file(s)") if not files: log("No media files found."); return checked = updated = misses = cached = 0 nfo_created = nfo_removed = sidecars_updated = nfo_ts_synced = 0 source_counts: Dict[str, int] = {} log(f"Starting processing: refresh={args.refresh}, dry_run={args.dry_run}, manage_nfo={args.manage_nfo}") for i, fp in enumerate(files, 1): path = str(fp) if i % 25 == 1 or i <= 5: log(f"Processing file {i}/{len(files)}: {fp.name}") checked += 1 # Cached branch if not args.refresh: cached_result = db_get_cached(cx, path) if cached_result: cached += 1 cached_ts, cached_src = cached_result if i <= 5: human = dt.datetime.utcfromtimestamp(cached_ts).strftime("%Y-%m-%d %H:%M:%S UTC") log(f" Using cached result: {cached_src} @ {human}") if apply_timestamp_to_file(fp, cached_ts, args.dry_run): updated += 1 for s in find_sidecar_files(fp): if apply_timestamp_to_file(s, cached_ts, args.dry_run): sidecars_updated += 1 if args.manage_nfo: date_added_str = dt.datetime.fromtimestamp(cached_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") if args.kind == "tv": epk = parse_episode_from_path(fp) if epk: air = dt.datetime.fromtimestamp(cached_ts, tz=dt.timezone.utc).strftime("%Y-%m-%d") if "airdate" in cached_src else None c, r, s = manage_nfo_files(fp.parent, "tv", epk.season, epk.episode, air, date_added_str, nfo_timestamp=cached_ts, dry_run=args.dry_run) nfo_created += c; nfo_removed += r; nfo_ts_synced += s else: mk = parse_movie_from_path(fp) c, r, s = manage_nfo_files(fp.parent, "movie", 0, 0, None, date_added_str, title=(mk.title if mk else None), year=(mk.year if mk else None), release_date=None, nfo_timestamp=cached_ts, dry_run=args.dry_run) nfo_created += c; nfo_removed += r; nfo_ts_synced += s continue # Fresh resolution ts_src_extra: Optional[Tuple[int, str, Dict[str, object]]] = None air_date_str = date_added_str = release_date_str = None if args.kind == "tv": epk = parse_episode_from_path(fp) if not epk: if i <= 10: log(f"WARN: Could not parse episode info from: {fp.name}") misses += 1; continue series_id = choose_series_id_for_path(series_index, fp) if series_index else None # Prefer Sonarr episode airdate (from cached per-series episodes) episode_result = None if scache and series_id: eps = scache.episodes(series_id) episode_result = pick_from_sonarr_episode_from_list(eps, epk.season, epk.episode) if episode_result: ts_src_extra = episode_result air_date_str = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc).strftime("%Y-%m-%d") date_added_str = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") # If we can find a *true import* for THIS episode, prefer that (original import date) ep_id = episode_result[2].get("episodeId") if ep_id: hist = scache.history(series_id) import_res = pick_from_sonarr_import_for_episode_from_history(hist, ep_id) if import_res: ts_src_extra = import_res # override with import/grabbed date_added_str = dt.datetime.fromtimestamp(import_res[0], tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") # If Sonarr missing, try TMDB (airdate fallback) if not ts_src_extra and tmdb_key: tm = pick_from_tmdb_tv(TMDBClient(tmdb_key), epk.series_title, epk.season, epk.episode, epk.imdb_id, epk.tmdb_id, epk.year) if tm: ts_src_extra = tm air_date_str = dt.datetime.fromtimestamp(tm[0], tz=dt.timezone.utc).strftime("%Y-%m-%d") date_added_str = dt.datetime.fromtimestamp(tm[0], tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S") else: mk = parse_movie_from_path(fp) if not mk: if i <= 10: log(f"WARN: Could not parse movie info from: {fp.name}") misses += 1; continue # (movie handling would mirror TV, omitted here for brevity) if not ts_src_extra and args.live_on_miss: try: mtime = int(fp.stat().st_mtime) ts_src_extra = (mtime, "existing_mtime", {}) if i <= 5: log(" Using existing mtime as fallback") except Exception: ts_src_extra = None if not ts_src_extra: if i <= 10: log(f"WARN: No date found for: {fp.name}") misses += 1; continue ts, src, extra = ts_src_extra source_counts[src] = source_counts.get(src, 0) + 1 skip_apply = False if args.only_older: try: cur_mtime = int(fp.stat().st_mtime) if ts >= cur_mtime: skip_apply = True if i <= 5: log(f" Skipping (only-older): chosen >= current") except Exception: pass if not skip_apply and apply_timestamp_to_file(fp, ts, args.dry_run): updated += 1 for s in find_sidecar_files(fp): if not skip_apply and apply_timestamp_to_file(s, ts, args.dry_run): sidecars_updated += 1 if args.manage_nfo: if args.kind == "tv": epk2 = parse_episode_from_path(fp) c, r, s = manage_nfo_files( fp.parent, "tv", epk2.season if epk2 else 0, epk2.episode if epk2 else 0, air_date_str, dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), nfo_timestamp=ts, dry_run=args.dry_run ) else: mk2 = parse_movie_from_path(fp) c, r, s = manage_nfo_files( fp.parent, "movie", 0, 0, None, dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), title=(mk2.title if mk2 else None), year=(mk2.year if mk2 else None), release_date=release_date_str, nfo_timestamp=ts, dry_run=args.dry_run ) nfo_created += c; nfo_removed += r; nfo_ts_synced += s if not args.dry_run: try: existing = db_get_cached(cx, path) if args.only_older and existing and ts >= existing[0]: pass else: db_upsert(cx, path, args.kind, ts, src, (extra or {})) except Exception as e: log(f"WARN: DB error: {e}") if i % 50 == 0: # more frequent progress for big shows log(f"Progress: {i}/{len(files)} (updated: {updated}, cached: {cached}, misses: {misses})") completion_msg = ( "Media date sync completed:\n" f"Kind: {args.kind}\n" f"Checked: {checked}\n" f"Updated: {updated}\n" f"Cached: {cached}\n" f"Misses: {misses}\n" f"NFO created: {nfo_created}, removed: {nfo_removed}\n" f"NFO timestamps synced: {nfo_ts_synced}\n" f"Sidecars updated: {sidecars_updated}" ) if source_counts: src_lines = "\n".join([f" - {k}: {v}" for k, v in sorted(source_counts.items(), key=lambda x: (-x[1], x[0]))]) completion_msg += f"\nSources Used:\n{src_lines}" log(completion_msg) send_gotify_notification("Media Date Sync Complete", completion_msg) # ================= # CLI # ================= def build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description="Apply canonical mtimes to media files using Sonarr/Radarr/TMDB fallbacks.") sub = p.add_subparsers(dest="cmd", required=True) ap = sub.add_parser("apply", help="Scan, resolve dates, apply mtimes, and record to DB.") ap.add_argument("--env", help="Optional .env file with API keys and URLs") ap.add_argument("--kind", choices=["tv", "movie"], required=True, help="Media kind") ap.add_argument("--db", required=True, help="SQLite DB file path") ap.add_argument("--root", action="append", help="Root folder(s) to scan; repeatable") ap.add_argument("--path", help="Process a single file or a single folder (overrides --root)") ap.add_argument("--only-older", action="store_true", help="Backfill only: only set earlier dates; never move to a later timestamp") ap.add_argument("--refresh", action="store_true", help="Ignore cached results and re-fetch all dates") ap.add_argument("--dry-run", action="store_true", help="Do not write mtime/DB; just log what would happen") ap.add_argument("--live-on-miss", action="store_true", help="If no date found, keep file's current mtime as chosen date") ap.add_argument("--manage-nfo", action="store_true", help="Create/refresh simple NFOs (TV: SxxEyy.nfo; Movies: movie.nfo).") ap.add_argument("--debug-http", action="store_true", help="Verbose log of all HTTP requests/responses (Sonarr/Radarr/TMDB)") return p def main() -> None: parser = build_arg_parser() args = parser.parse_args() try: if args.cmd == "apply": apply_command(args) else: parser.error("unknown command") except KeyboardInterrupt: log("Interrupted."); sys.exit(1) except Exception as e: log(f"FATAL ERROR: {e}") import traceback; traceback.print_exc(); sys.exit(1) if __name__ == "__main__": main()