#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Apply canonical "date" timestamps (mtime) to media files and cache results. Priority (TV): 1) Sonarr history/import date (first successful import/release) 2) Sonarr episode 'airDate' (UTC midnight) 3) TMDB episode 'air_date' 4) (optional, with --live-on-miss) keep existing mtime Priority (Movies): 1) Radarr history/import date (first successful import/release) 2) Radarr movie 'digitalRelease' or 'physicalRelease' 3) TMDB movie 'release_date' 4) (optional, with --live-on-miss) keep existing mtime DB schema (SQLite): 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')) ); Usage (examples): python media_date_cache.py apply \ --env /opt/scripts/unmanic/.env \ --kind tv \ --db /opt/scripts/unmanic/.cache/media_dates.db \ --root "/mnt/unionfs/Media/TV" \ --refresh --live-on-miss python media_date_cache.py apply \ --env /opt/scripts/unmanic/.env \ --kind movie \ --db /opt/scripts/unmanic/.cache/media_dates.db \ --root "/mnt/unionfs/Media/Movies" \ --refresh --live-on-miss """ from __future__ import annotations import argparse import dataclasses import datetime as dt import json import os import re import sqlite3 import sys import time from pathlib import Path from typing import Dict, Iterable, List, Optional, Tuple import requests # ========================= # Defaults / ENV variables # ========================= DEFAULT_SONARR_URL = os.getenv("SONARR_URL", "http://sonarr:8989") DEFAULT_SONARR_API_KEY = os.getenv("SONARR_API_KEY", "") DEFAULT_RADARR_URL = os.getenv("RADARR_URL", "http://radarr:7878") DEFAULT_RADARR_API_KEY = os.getenv("RADARR_API_KEY", "") DEFAULT_TMDB_API_KEY = os.getenv("TMDB_API_KEY", "") DEFAULT_TMDB_PRIMARY_COUNTRY = os.getenv("TMDB_PRIMARY_COUNTRY", "US") DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "") DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "") # Multi-root support from environment TV_ROOTS = os.getenv("TV_ROOTS", "").split(":") if os.getenv("TV_ROOTS") else [] MOVIE_ROOTS = os.getenv("MOVIE_ROOTS", "").split(":") if os.getenv("MOVIE_ROOTS") else [] 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) # File parsing patterns 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})\)") def log(msg: str) -> None: ts = dt.datetime.now().strftime(LOG_TS_FMT) print(f"[{ts}] {msg}", flush=True) def send_gotify_notification(title: str, message: str, priority: int = 5) -> None: """Send notification to Gotify server if configured.""" if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN: return try: requests.post( f"{DEFAULT_GOTIFY_URL}/message", data={ "title": title, "message": message, "priority": priority }, headers={"X-Gotify-Key": DEFAULT_GOTIFY_TOKEN}, timeout=10 ) except Exception as e: log(f"WARN: Gotify notification failed: {e}") def load_env_file(path: Optional[str]) -> None: """Load a simple KEY=VALUE .env file.""" if not path: return p = Path(path) if not p.is_file(): log(f"WARN: .env file not found: {path}") return log(f"Loading environment from: {path}") for line in p.read_text().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 optional surrounding quotes if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')): v = v[1:-1] os.environ.setdefault(k, v) # ================= # SQLite helpers # ================= 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]]: """Get cached timestamp and source for a path.""" cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,)) row = cur.fetchone() if row: return row[0], row[1] return 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 helpers # ================= def _req_json( method: str, url: str, *, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, object]] = None, timeout: int = 30, retries: int = 3, backoff: float = 2.0, ) -> dict: """Request JSON with retries/backoff; raise on final failure.""" last_err = None for attempt in range(1, retries + 1): try: r = requests.request(method, url, headers=headers or {}, params=params or {}, timeout=timeout) r.raise_for_status() return r.json() except (requests.exceptions.Timeout, requests.exceptions.ReadTimeout) as e: last_err = e except requests.exceptions.RequestException as e: last_err = e if attempt < retries: time.sleep(backoff ** attempt) raise last_err # type: ignore[misc] # ================= # Sonarr client # ================= @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]: return _req_json( "GET", f"{self.url}/api/v3/series", headers=self._h(), timeout=self.timeout, retries=self.retries ) def episodes_for(self, series_id: int) -> List[dict]: return _req_json( "GET", f"{self.url}/api/v3/episode", headers=self._h(), params={"seriesId": series_id}, timeout=self.timeout, retries=self.retries ) def history_for(self, series_id: int) -> List[dict]: res = _req_json( "GET", f"{self.url}/api/v3/history", headers=self._h(), params={ "page": 1, "pageSize": 1000, "sortKey": "date", "sortDirection": "ascending", "seriesId": series_id, "eventType": 3 # Downloaded/Imported events only }, timeout=self.timeout, retries=self.retries ) return res.get("records", []) # ================= # Radarr client # ================= @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]: return _req_json( "GET", f"{self.url}/api/v3/movie", headers=self._h(), timeout=self.timeout, retries=self.retries ) def history_for(self, movie_id: int) -> List[dict]: res = _req_json( "GET", f"{self.url}/api/v3/history", headers=self._h(), params={ "page": 1, "pageSize": 1000, "sortKey": "date", "sortDirection": "ascending", "movieId": movie_id, "eventType": 3 # Downloaded/Imported events only }, timeout=self.timeout, retries=self.retries ) return res.get("records", []) # ================= # TMDB client # ================= @dataclasses.dataclass class TMDBClient: api_key: str country: str = DEFAULT_TMDB_PRIMARY_COUNTRY 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} 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 ) tv = js.get("tv_results") or [] if tv: return tv[0].get("id") return 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 ) movies = js.get("movie_results") or [] if movies: return movies[0].get("id") return None def search_tv(self, name: str, first_air_date_year: Optional[int]) -> Optional[int]: js = _req_json( "GET", f"{self.base_url}/search/tv", params=self._p(query=name, first_air_date_year=first_air_date_year or ""), timeout=self.timeout, retries=self.retries ) res = js.get("results") or [] if res: return res[0].get("id") return 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") or [] if res: return res[0].get("id") return 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") 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") except Exception: return None # ================= # File parsing # ================= @dataclasses.dataclass class EpisodeKey: series_title: str year: Optional[int] imdb_id: Optional[str] tmdb_id: Optional[str] season: int episode: int @dataclasses.dataclass class MovieKey: title: str year: Optional[int] imdb_id: Optional[str] tmdb_id: Optional[str] def parse_episode_from_path(path: Path) -> Optional[EpisodeKey]: """Parse episode info from file path.""" m = SE_EP_RX.search(path.name) if not m: return None season = int(m.group(1)) episode = int(m.group(2)) series_dir = path.parent if series_dir.name.lower().startswith("season "): series_dir = series_dir.parent series_title = series_dir.name imdb_id = None tmdb_id = None year = None m2 = IMDB_RX.search(series_title) if m2: imdb_id = m2.group(1) m3 = TMDB_RX.search(series_title) if m3: tmdb_id = m3.group(1) m4 = YEAR_RX.search(series_title) if m4: try: year = int(m4.group(1)) except ValueError: year = None clean_title = IMDB_RX.sub("", series_title) clean_title = TMDB_RX.sub("", clean_title) clean_title = YEAR_RX.sub("", clean_title) clean_title = clean_title.strip() if not clean_title: clean_title = series_title return EpisodeKey(clean_title, year, imdb_id, tmdb_id, season, episode) def parse_movie_from_path(path: Path) -> Optional[MovieKey]: """Parse movie info from file path.""" movie_dir = path.parent movie_title = movie_dir.name imdb_id = None tmdb_id = None year = None m1 = IMDB_RX.search(movie_title) if m1: imdb_id = m1.group(1) m2 = TMDB_RX.search(movie_title) if m2: tmdb_id = m2.group(1) m3 = YEAR_RX.search(movie_title) if m3: try: year = int(m3.group(1)) except ValueError: year = None clean_title = IMDB_RX.sub("", movie_title) clean_title = TMDB_RX.sub("", clean_title) clean_title = YEAR_RX.sub("", clean_title) clean_title = clean_title.strip() if not clean_title: clean_title = movie_title return MovieKey(clean_title, year, imdb_id, tmdb_id) # ================= # Date choosing # ================= 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_history(sc: SonarrClient, series_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]: """Return (unix_ts, src, extra) or None.""" try: hist = sc.history_for(series_id) except Exception as e: log(f"WARN: Sonarr history timeout/err series={series_id}: {e}") return None best_ts = None best = None for rec in hist: event_type = rec.get("eventType") if event_type not in [1, 3]: # 1=Grabbed, 3=Downloaded continue 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 best_ts is None or ts < best_ts: best_ts = ts best = rec if best_ts is not None: return best_ts, "sonarr_history", {"history_id": best.get("id"), "event_type": best.get("eventType")} return None def pick_from_sonarr_episode(sc: SonarrClient, series_id: int, season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]: try: eps = sc.episodes_for(series_id) except Exception as e: log(f"WARN: Sonarr episodes timeout/err series={series_id}: {e}") return None for ep in eps: if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode: air = ep.get("airDate") or ep.get("airDateUtc") 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 # Similar functions for Radarr and TMDB... def pick_from_tmdb_tv(tmdb: TMDBClient, epk: EpisodeKey) -> Optional[Tuple[int, str, Dict[str, object]]]: if not tmdb.api_key: return None tv_id: Optional[int] = None if epk.tmdb_id: try: tv_id = int(epk.tmdb_id) except (ValueError, TypeError): tv_id = None if tv_id is None and epk.imdb_id: try: tv_id = tmdb.find_tv_by_imdb(epk.imdb_id) except Exception as e: log(f"WARN: TMDB find by IMDb failed {epk.imdb_id}: {e}") if tv_id is None: try: tv_id = tmdb.search_tv(epk.series_title, epk.year) except Exception as e: log(f"WARN: TMDB search failed title={epk.series_title}: {e}") if tv_id is None: return None air = tmdb.episode_air_date(tv_id, epk.season, epk.episode) if not air: return None ts = parse_date_yyyy_mm_dd(air) if ts is None: return None return ts, "tmdb_airdate", {"tv_id": tv_id, "country": tmdb.country} # ================= # NFO Management # ================= def create_simple_nfo(season: int, episode: int, air_date: Optional[str] = None, date_added: Optional[str] = None) -> str: """Create a simple NFO file content for SxxExx.nfo format.""" 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: """Create a simple NFO file content for movie.nfo format.""" 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 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, dry_run: bool = False ) -> Tuple[int, int]: """Manage NFO files. Returns (created_count, removed_count).""" created = 0 removed = 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) if not dry_run: 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: simple_nfo_path.write_text(nfo_content, encoding='utf-8') created = 1 except Exception as e: log(f"WARN: Failed to create {simple_nfo_path}: {e}") else: # For dry run, check if we would create/update if not simple_nfo_path.exists(): created = 1 else: try: existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore') if existing_content.strip() != nfo_content.strip(): created = 1 except Exception: created = 1 # Remove complex NFO files (but keep simple ones and season.nfo) for nfo_file in media_dir.glob("*.nfo"): nfo_name = nfo_file.name.lower() # Keep these files if NFO_SIMPLE_PATTERN.match(nfo_file.name): continue # Keep S01E01.nfo format if nfo_name == "season.nfo": continue # Keep season.nfo if nfo_name == "tvshow.nfo": continue # Keep tvshow.nfo # This is a complex NFO file - remove it if not dry_run: try: nfo_file.unlink() log(f"Removed complex NFO: {nfo_file.name}") removed += 1 except Exception as e: log(f"WARN: Failed to remove {nfo_file}: {e}") else: log(f"Would remove complex NFO: {nfo_file.name}") removed += 1 return created, removed # ================= # Sidecar files # ================= def find_sidecar_files(media_file: Path) -> List[Path]: """Find sidecar files (subtitles, etc.) that match the media file.""" sidecars = [] base_name = media_file.stem parent_dir = media_file.parent for ext in SIDECAR_EXTS: exact_match = parent_dir / f"{base_name}{ext}" if exact_match.exists(): sidecars.append(exact_match) for lang_file in parent_dir.glob(f"{base_name}.*{ext}"): if lang_file not in sidecars: sidecars.append(lang_file) return sidecars def apply_timestamp_to_file(file_path: Path, timestamp: int, dry_run: bool = False) -> bool: """Apply timestamp to a file. Returns True if successful.""" 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 # ================= # Scanning # ================= def iter_media_files(roots: List[str]) -> Iterable[Path]: for root in roots: r = Path(root) if not r.exists(): log(f"WARN: Root path does not exist: {root}") continue if r.is_file(): if r.suffix.lower() in VIDEO_EXTS: yield r continue for p in r.rglob("*"): if p.is_file() and p.suffix.lower() in VIDEO_EXTS: yield p def choose_series_id_for_path(series_index: List[dict], path: Path) -> Optional[int]: """Try to map a path to a Sonarr seriesId by matching the series' 'path' as a prefix.""" series_dir = path.parent if series_dir.name.lower().startswith("season "): series_dir = series_dir.parent sdir = str(series_dir) best_len = -1 best_sid = None for s in series_index: spath = s.get("path", "") if spath and sdir.startswith(spath): L = len(spath) if L > best_len: best_len = L best_sid = s.get("id") return best_sid # ================= # Apply command # ================= def apply_command(args: argparse.Namespace) -> None: """Main apply command function.""" log("=== Media Date Sync Starting ===") # Load environment first load_env_file(args.env) # NOW get the updated environment variables 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") # Debug: Show what we loaded 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'}") # Resolve clients with updated environment sc = 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") else: log("WARN: Sonarr not configured") tmdb = TMDBClient(tmdb_key, tmdb_country) if not tmdb_key: log("WARN: TMDB not configured - limited functionality") # Initialize database cx = sqlite3.connect(args.db) db_init(cx) log(f"Database initialized: {args.db}") # Load Sonarr series index 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}") # Determine root paths roots = args.root or [] if not roots: tv_roots = os.getenv("TV_ROOTS", "").split(":") if os.getenv("TV_ROOTS") else [] movie_roots = os.getenv("MOVIE_ROOTS", "").split(":") if os.getenv("MOVIE_ROOTS") else [] if args.kind == "tv" and tv_roots: roots = [r for r in tv_roots if r.strip()] log(f"Using TV_ROOTS from environment: {', '.join(roots)}") elif args.kind == "movie" and movie_roots: roots = [r for r in movie_roots if r.strip()] log(f"Using MOVIE_ROOTS from environment: {', '.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 = list(iter_media_files(roots)) log(f"Found {len(files)} media file(s)") if not files: log("No media files found. Check your root paths and permissions.") return # Processing counters checked = 0 updated = 0 misses = 0 cached = 0 nfo_created = 0 nfo_removed = 0 sidecars_updated = 0 log(f"Starting processing: refresh={args.refresh}, dry_run={args.dry_run}, manage_nfo={args.manage_nfo}") for fp in files: checked += 1 path = str(fp) if checked <= 5: # Show detailed info for first 5 files log(f"Processing file {checked}: {fp.name}") # Check cache first (unless refresh requested) if not args.refresh: cached_result = db_get_cached(cx, path) if cached_result: cached_ts, cached_src = cached_result if checked <= 5: log(f" Using cached result: {cached_src}") # Apply cached timestamp to media file if apply_timestamp_to_file(fp, cached_ts, args.dry_run): cached += 1 # Also update sidecar files sidecar_files = find_sidecar_files(fp) for sidecar in sidecar_files: if apply_timestamp_to_file(sidecar, cached_ts, args.dry_run): sidecars_updated += 1 continue # Parse media file based on type ts_src_extra: Optional[Tuple[int, str, Dict[str, object]]] = None air_date_str = None date_added_str = None if args.kind == "tv": epk = parse_episode_from_path(fp) if not epk: if checked <= 10: # Only log first 10 parse failures to avoid spam log(f"WARN: Could not parse episode info from: {fp.name}") misses += 1 continue if checked <= 5: log(f" Parsed: {epk.series_title} S{epk.season:02d}E{epk.episode:02d}") # Try Sonarr first series_id = choose_series_id_for_path(series_index, fp) if series_index else None if checked <= 5: log(f" Sonarr series ID: {series_id}") if sc and series_id: if checked <= 5: log(f" Checking Sonarr history...") # Try history first (import date) history_result = pick_from_sonarr_history(sc, series_id) if history_result: ts_src_extra = history_result if checked <= 5: log(f" Got history: {history_result[1]}") try: date_added_dt = dt.datetime.fromtimestamp(history_result[0], tz=dt.timezone.utc) date_added_str = date_added_dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: pass if checked <= 5: log(f" Checking Sonarr episode data...") # Get air date regardless episode_result = pick_from_sonarr_episode(sc, series_id, epk.season, epk.episode) if episode_result: if not ts_src_extra: # Use air date if no history ts_src_extra = episode_result if not date_added_str: try: date_added_dt = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc) date_added_str = date_added_dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: pass # Extract air date for NFO try: air_date_ts = episode_result[0] air_date_dt = dt.datetime.fromtimestamp(air_date_ts, tz=dt.timezone.utc) air_date_str = air_date_dt.strftime("%Y-%m-%d") except Exception: pass # TMDB fallback if not ts_src_extra and tmdb_key: if checked <= 5: log(f" Trying TMDB fallback...") tmdb_result = pick_from_tmdb_tv(tmdb, epk) if tmdb_result: ts_src_extra = tmdb_result if checked <= 5: log(f" Got TMDB result: {tmdb_result[1]}") try: air_date_ts = tmdb_result[0] air_date_dt = dt.datetime.fromtimestamp(air_date_ts, tz=dt.timezone.utc) air_date_str = air_date_dt.strftime("%Y-%m-%d") if not date_added_str: date_added_str = air_date_dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: pass else: # movie support would go here log("Movie support not implemented yet") misses += 1 continue # Last resort: keep existing mtime if allowed if not ts_src_extra and args.live_on_miss: try: mtime = int(fp.stat().st_mtime) ts_src_extra = (mtime, "existing_mtime", {}) if checked <= 5: log(f" Using existing mtime as fallback") except Exception: ts_src_extra = None if not ts_src_extra: if checked <= 10: # Only log first 10 misses to avoid spam log(f"WARN: No date found for: {fp.name}") misses += 1 continue ts, src, extra = ts_src_extra if checked <= 5: log(f" Final source: {src}, timestamp: {dt.datetime.fromtimestamp(ts)}") # Apply timestamp to media file if apply_timestamp_to_file(fp, ts, args.dry_run): updated += 1 # Apply timestamp to sidecar files sidecar_files = find_sidecar_files(fp) if sidecar_files and checked <= 5: log(f" Found {len(sidecar_files)} sidecar files") for sidecar in sidecar_files: if apply_timestamp_to_file(sidecar, ts, args.dry_run): sidecars_updated += 1 # Handle NFO files for TV shows if args.kind == "tv" and args.manage_nfo: created, removed = manage_nfo_files( fp.parent, "tv", epk.season, epk.episode, air_date_str, date_added_str, dry_run=args.dry_run ) if (created or removed) and checked <= 5: log(f" NFO: created={created}, removed={removed}") nfo_created += created nfo_removed += removed # Update database if not args.dry_run: try: if args.kind == "tv": extra_data = { **(extra or {}), "series_title": epk.series_title, "season": epk.season, "episode": epk.episode, } else: extra_data = extra or {} db_upsert(cx, path, args.kind, ts, src, extra_data) if checked <= 5: # Debug for first few files log(f" Database updated: {src}") except sqlite3.OperationalError as e: log(f"WARN: DB write failed: {e}") except Exception as e: log(f"WARN: Unexpected DB error: {e}") # Progress updates if checked % 500 == 0: log(f"Progress: {checked}/{len(files)} (updated: {updated}, cached: {cached}, misses: {misses})") # Final results completion_msg = f"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"Sidecars updated: {sidecars_updated}\n" if args.manage_nfo: completion_msg += f"NFO created: {nfo_created}\n" \ f"NFO removed: {nfo_removed}\n" completion_msg += f"Dry run: {args.dry_run}" log(f"=== Complete ===") log(completion_msg.replace('\n', ', ')) # Send notification 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) # apply 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 (or use TV_ROOTS/MOVIE_ROOTS in .env)") 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 simple NFO files with air/release dates and date added (import dates). Remove complex NFOs.") return p def main() -> None: try: parser = build_arg_parser() args = parser.parse_args() 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()