From 8cc870996b88023a140c2a4a89e51208d036fe32 Mon Sep 17 00:00:00 2001 From: jskala Date: Sun, 24 Aug 2025 12:02:34 -0400 Subject: [PATCH] upload --- __pycache__/media_date_cache.cpython-312.pyc | Bin 0 -> 42333 bytes debug_test.py | 37 + logs/tv_apply_2025-08-24_1000.log | 32 + media_date_cache.BEST | 1119 +++++++++++++++++ media_date_cache.py | 1124 ++++++++++++++++++ media_date_cache.working | 860 ++++++++++++++ media_date_cache.workingbest | 697 +++++++++++ media_date_cache.workingslow | 942 +++++++++++++++ media_dates.db | Bin 0 -> 151552 bytes media_dates_tv.db | 0 10 files changed, 4811 insertions(+) create mode 100644 __pycache__/media_date_cache.cpython-312.pyc create mode 100644 debug_test.py create mode 100644 logs/tv_apply_2025-08-24_1000.log create mode 100644 media_date_cache.BEST create mode 100644 media_date_cache.py create mode 100755 media_date_cache.working create mode 100755 media_date_cache.workingbest create mode 100755 media_date_cache.workingslow create mode 100644 media_dates.db create mode 100644 media_dates_tv.db diff --git a/__pycache__/media_date_cache.cpython-312.pyc b/__pycache__/media_date_cache.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3297d9ca191124e5293246c0c5700e92d076dd28 GIT binary patch literal 42333 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 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 0000000000000000000000000000000000000000..d667f671053c9d7c07e1bc232852b583de1ecf03 GIT binary patch literal 151552 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