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