diff --git a/media_date_cache.py b/media_date_cache.py new file mode 100644 index 0000000..3ea74ae --- /dev/null +++ b/media_date_cache.py @@ -0,0 +1,1270 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import os, sys, re, json, sqlite3, argparse, time +from pathlib import Path +from datetime import datetime, timezone +import urllib.request, urllib.parse + +# ========================= +# tiny .env loader + --env anywhere +# ========================= +def _iso_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + +def _log(level, msg): + # Only show DEBUG messages if debug mode is enabled + if level == "DEBUG" and not getattr(_log, 'debug_enabled', False): + return + print(f"[{_iso_now()}] {level}: {msg}") + +def _load_env_file(path: str) -> bool: + p = Path(path) + if not p.exists(): + _log("WARNING", f".env not found path={path}") + return False + loaded = 0 + for raw in p.read_text(encoding="utf-8").splitlines(): + 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().strip('"').strip("'") + os.environ[k] = v + loaded += 1 + # normalize variants + if "SONARR_API_KEY" not in os.environ and "SONARR_APIKEY" in os.environ: + os.environ["SONARR_API_KEY"] = os.environ["SONARR_APIKEY"] + if "RADARR_API_KEY" not in os.environ and "RADARR_APIKEY" in os.environ: + os.environ["RADARR_API_KEY"] = os.environ["RADARR_APIKEY"] + _log("INFO", f".env loaded from {path} ({loaded} keys)") + return True + +def _preparse_env(argv): + env_path = None + new_argv = [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--env" and i + 1 < len(argv): + env_path = argv[i + 1] + i += 2 + continue + elif a.startswith("--env="): + env_path = a.split("=", 1)[1] + i += 1 + continue + new_argv.append(a) + i += 1 + if env_path: + if not _load_env_file(env_path): + _log("WARNING", f".env not loaded path={env_path}") + return new_argv + +sys.argv = _preparse_env(sys.argv) + +# ========================= +# DB schema / connect +# ========================= +SCHEMA = """ +PRAGMA journal_mode=WAL; + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS series ( + imdb_id TEXT PRIMARY KEY, + series_path TEXT NOT NULL, + last_applied TEXT +); + +CREATE TABLE IF NOT EXISTS episode_dates ( + imdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + aired TEXT, + dateadded TEXT, + source TEXT, + has_video_file INTEGER DEFAULT 0, + PRIMARY KEY (imdb_id, season, episode) +); +""" + +def db_connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.Connection(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON;") + + # Check if tables exist and have correct schema + try: + # Test if episode_dates table exists with correct columns + result = conn.execute("PRAGMA table_info(episode_dates);").fetchall() + columns = [row[1] for row in result] # row[1] is column name + required_columns = ['imdb_id', 'season', 'episode', 'aired', 'dateadded', 'source'] + + if not all(col in columns for col in required_columns): + _log("WARNING", "Database schema outdated, recreating tables...") + conn.execute("DROP TABLE IF EXISTS episode_dates;") + conn.execute("DROP TABLE IF EXISTS series;") + conn.execute("DROP TABLE IF EXISTS meta;") + elif 'has_video_file' not in columns: + _log("INFO", "Adding has_video_file column to episode_dates table") + conn.execute("ALTER TABLE episode_dates ADD COLUMN has_video_file INTEGER DEFAULT 0;") + + except sqlite3.OperationalError: + # Tables don't exist, which is fine + pass + + # Create/recreate schema + conn.executescript(SCHEMA) + + # Verify schema was created correctly + try: + conn.execute("SELECT imdb_id FROM episode_dates LIMIT 0;") + _log("DEBUG", "Database schema verified") + except sqlite3.OperationalError as e: + _log("ERROR", f"Database schema verification failed: {e}") + raise + + return conn + +# ========================= +# HTTP helpers +# ========================= +def http_get_json(url, headers=None, timeout=45): + req = urllib.request.Request(url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + if not data: + return None + try: + return json.loads(data.decode("utf-8")) + except Exception as e: + _log("WARNING", f"Failed to parse JSON from {url}: {e}") + return None + except urllib.error.HTTPError as e: + _log("ERROR", f"HTTP {e.code} error for {url}: {e.reason}") + if e.code == 401: + _log("ERROR", "Authentication failed - check your API keys") + elif e.code == 429: + _log("WARNING", "Rate limited - waiting before retry") + time.sleep(2) + raise # Re-raise so caller can handle + except urllib.error.URLError as e: + _log("ERROR", f"Network error for {url}: {e}") + return None + except Exception as e: + _log("ERROR", f"Unexpected error for {url}: {e}") + return None + +def to_utc_iso(s): + # Accepts '2025-08-25T01:27:00Z' or '+00:00' or '2011-12-04' + if not s: + return None + try: + if len(s) == 10 and s[4] == "-" and s[7] == "-": + # date only -> midnight UTC + return datetime.fromisoformat(s).replace(tzinfo=timezone.utc).isoformat(timespec="seconds") + # normalize Z to +00:00 + s2 = s.replace("Z", "+00:00") + dt = datetime.fromisoformat(s2) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat(timespec="seconds") + except Exception: + return None + +def iso_from_epoch(epoch): + try: + return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds") + except Exception: + return None + +def file_mtime_iso(p: Path): + try: + return iso_from_epoch(p.stat().st_mtime) + except Exception: + return None + +# ========================= +# Source clients +# ========================= +class SonarrClient: + def __init__(self): + self.base = os.environ.get("SONARR_URL", "").rstrip("/") + self.key = os.environ.get("SONARR_API_KEY", "") + self.timeout = int(os.environ.get("SONARR_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("SONARR_RETRIES", "3") or "3") + self.enabled = bool(self.base and self.key) + + def _get(self, path, params=None): + if not self.enabled: return None + q = f"{self.base}/api/v3{path}" + if params: + qs = urllib.parse.urlencode(params) + q = f"{q}?{qs}" + headers = {"X-Api-Key": self.key} + + for attempt in range(self.retries): + try: + j = http_get_json(q, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"Sonarr authentication failed - check SONARR_API_KEY") + return None # Don't retry auth failures + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"Sonarr rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}") + except Exception as e: + _log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(0.5 * (attempt + 1)) # Progressive backoff + + _log("ERROR", f"Sonarr API failed after {self.retries} attempts: {q}") + return None + + def series_by_imdb(self, imdb_id): + # Sonarr supports /series/lookup?term=imdbid:tt1234567 + search_term = f"imdbid:{imdb_id}" + _log("DEBUG", f"Searching Sonarr with term: {search_term}") + + j = self._get("/series/lookup", {"term": search_term}) + if not j: + _log("WARNING", f"No results returned from Sonarr lookup for: {search_term}") + return None + + _log("DEBUG", f"Sonarr lookup returned {len(j)} results") + + # Log all results for debugging + for i, s in enumerate(j): + series_imdb = s.get("imdbId", "") + series_title = s.get("title", "") + series_id = s.get("id", "") + _log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}") + + # pick exact imdb id match (case insensitive) + target_imdb = imdb_id.lower() + for s in j: + series_imdb = (s.get("imdbId") or "").lower() + if series_imdb == target_imdb: + _log("INFO", f"Found exact IMDb match: {s.get('title')} (ID: {s.get('id')})") + return s + + # If no exact match, try partial match (in case of formatting differences) + for s in j: + series_imdb = (s.get("imdbId") or "").lower() + if target_imdb in series_imdb or series_imdb in target_imdb: + _log("WARNING", f"Found partial IMDb match: {s.get('title')} (Expected: {imdb_id}, Found: {s.get('imdbId')})") + return s + + _log("WARNING", f"No IMDb match found in {len(j)} results for {imdb_id}") + return None + + def series_by_title(self, title): + """Search for series by title as fallback when IMDb lookup fails""" + _log("DEBUG", f"Searching Sonarr by title: {title}") + + # Try direct search first + j = self._get("/series/lookup", {"term": title}) + if not j: + _log("WARNING", f"No results returned from Sonarr title search for: {title}") + return None + + _log("DEBUG", f"Sonarr title search returned {len(j)} results") + + # Look for exact or close title match + title_lower = title.lower() + for s in j: + series_title = (s.get("title") or "").lower() + if series_title == title_lower: + _log("INFO", f"Found exact title match: {s.get('title')} (ID: {s.get('id')})") + return s + + # If no exact match, try partial match + for s in j: + series_title = (s.get("title") or "").lower() + if title_lower in series_title or series_title in title_lower: + _log("INFO", f"Found partial title match: '{s.get('title')}' for search '{title}' (ID: {s.get('id')})") + return s + + _log("WARNING", f"No title match found for: {title}") + return None + + def get_all_series(self): + """Get all series from Sonarr to find by IMDb ID directly""" + return self._get("/series") or [] + + def series_by_imdb_direct(self, imdb_id): + """Find series by scanning all series for IMDb match (slower but more reliable)""" + _log("DEBUG", f"Trying direct series lookup for IMDb: {imdb_id}") + all_series = self.get_all_series() + + target_imdb = imdb_id.lower() + for series in all_series: + series_imdb = (series.get("imdbId") or "").lower() + if series_imdb == target_imdb: + _log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})") + return series + + _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") + return None + + def episodes_for_series(self, series_id): + return self._get("/episode", {"seriesId": series_id}) or [] + + def episode_file(self, episode_file_id): + return self._get(f"/episodefile/{episode_file_id}") + + def get_episode_import_history(self, episode_id): + """Get the original import date from history, not just the latest""" + # Get ALL history for this episode by fetching multiple pages + all_records = [] + page = 1 + page_size = 100 + + while True: + history = self._get("/history", { + "episodeId": episode_id, + "sortKey": "date", + "sortDir": "asc", + "page": page, + "pageSize": page_size + }) or {} + + records = history.get("records", []) + if not records: + break + + all_records.extend(records) + + # Check if we got all records (if we got less than pageSize, we're done) + if len(records) < page_size: + break + + page += 1 + if page > 10: # Safety valve - don't go crazy + break + + _log("DEBUG", f"Got {len(all_records)} total history records for episode {episode_id}") + + # Look for ACTUAL import events (not just renames) + import_events = [] + grabbed_events = [] + + for event in all_records: + event_type = event.get("eventType") + date = event.get("date") + + _log("DEBUG", f"History event: {event_type} at {date}") + + # Primary import event - this is what we want + if event_type == "downloadFolderImported" and date: + import_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Track grab events as secondary indicators + elif event_type == "grabbed" and date: + grabbed_events.append({ + "date": date, + "sourceTitle": event.get("sourceTitle", ""), + "eventType": event_type + }) + + # Use downloadFolderImported events if we have them - GET THE EARLIEST ONE + if import_events: + earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31") + import_date = earliest["date"] + _log("INFO", f"Found original import date: {import_date} (downloadFolderImported) for episode {episode_id}") + + # ADDITIONAL CHECK: If this import is very recent compared to rename events, + # and we have rename events from much earlier, this might be an upgrade + _log("DEBUG", f"Starting upgrade detection check for episode {episode_id}") + if import_date: + _log("DEBUG", f"Import date exists: {import_date}") + # Look for rename events that are much earlier than this import + all_renames = [e for e in all_records + if e.get("eventType") == "episodeFileRenamed" + and e.get("date")] + + _log("DEBUG", f"Found {len(all_renames)} rename events for episode {episode_id}") + + if all_renames: + earliest_rename = min(all_renames, key=lambda x: x.get("date", "9999-12-31")) + rename_date = earliest_rename.get("date") + + _log("DEBUG", f"Earliest rename: {rename_date}, Latest import: {import_date}") + + # If rename is significantly earlier (>30 days), prefer it + try: + import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00")) + rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00")) + days_diff = (import_dt - rename_dt).days + + _log("DEBUG", f"Time difference: {days_diff} days (import after rename)") + + if days_diff > 30: + _log("WARNING", f"Import date {import_date} is {days_diff} days after earliest rename {rename_date} - likely upgrade, using rename date") + return rename_date + else: + _log("DEBUG", f"Time difference ({days_diff} days) is acceptable, using import date") + except Exception as e: + _log("DEBUG", f"Error comparing dates: {e}") + else: + _log("DEBUG", f"No rename events found for upgrade detection") + else: + _log("DEBUG", f"No import date for upgrade detection") + + return import_date + + # If no import events but we have grabs, use the earliest grab + some buffer time + # This handles cases where history is incomplete but we know when it was grabbed + if grabbed_events: + earliest_grab = min(grabbed_events, key=lambda x: x["date"] or "9999-12-31") + _log("WARNING", f"No downloadFolderImported found, using grabbed event: {earliest_grab['date']} for episode {episode_id}") + return earliest_grab["date"] + + _log("WARNING", f"No reliable import events found for episode {episode_id}") + return None + +class TMDBClient: + def __init__(self): + self.api_key = os.environ.get("TMDB_API_KEY", "") + self.primary_country = os.environ.get("TMDB_PRIMARY_COUNTRY", "US") + self.timeout = int(os.environ.get("TMDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TMDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + + def _get(self, path, params=None): + if not self.enabled: return None + base = "https://api.themoviedb.org/3" + params = params or {} + params["api_key"] = self.api_key + url = f"{base}{path}?{urllib.parse.urlencode(params)}" + + for attempt in range(self.retries): + try: + j = http_get_json(url, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + _log("ERROR", f"TMDB authentication failed - check TMDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 3 + _log("WARNING", f"TMDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TMDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TMDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def tv_id_from_imdb(self, imdb_id): + j = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) + if not j: return None + tv = (j.get("tv_results") or []) + if tv: + return tv[0].get("id") + # sometimes series is treated as multi-type; try "tv_episode" find to resolve parent? + return None + + def season_airdates(self, tv_id, season_number): + # returns {episode_number: air_date or None} + j = self._get(f"/tv/{tv_id}/season/{season_number}") + res = {} + if not j: return res + for ep in (j.get("episodes") or []): + num = ep.get("episode_number") + air = ep.get("air_date") + if isinstance(num, int) and air: + res[num] = air + return res + +class TVDBClient: + def __init__(self): + self.api_key = os.environ.get("TVDB_API_KEY", "") + self.timeout = int(os.environ.get("TVDB_TIMEOUT", "45") or "45") + self.retries = int(os.environ.get("TVDB_RETRIES", "3") or "3") + self.enabled = bool(self.api_key) + self.token = None + self.token_expires = 0 + + def _get_token(self): + """Get JWT token for TVDB API""" + if self.token and time.time() < self.token_expires: + return self.token + + url = "https://api4.thetvdb.com/v4/login" + headers = {"Content-Type": "application/json"} + data = json.dumps({"apikey": self.api_key}).encode('utf-8') + + try: + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + result = json.loads(resp.read().decode('utf-8')) + self.token = result["data"]["token"] + # Token expires in 24 hours, refresh after 23 hours + self.token_expires = time.time() + (23 * 60 * 60) + return self.token + except Exception as e: + _log("ERROR", f"TVDB authentication failed: {e}") + return None + + def _get(self, path, params=None): + if not self.enabled: + return None + + token = self._get_token() + if not token: + return None + + url = f"https://api4.thetvdb.com/v4{path}" + if params: + url += "?" + urllib.parse.urlencode(params) + + headers = {"Authorization": f"Bearer {token}"} + + for attempt in range(self.retries): + try: + j = http_get_json(url, headers=headers, timeout=self.timeout) + if j is not None: + return j + except urllib.error.HTTPError as e: + if e.code == 401: + # Token might be expired, clear it and retry once + if attempt == 0: + self.token = None + self.token_expires = 0 + continue + _log("ERROR", f"TVDB authentication failed - check TVDB_API_KEY") + return None + elif e.code == 429: + wait_time = (attempt + 1) * 2 + _log("WARNING", f"TVDB rate limited, waiting {wait_time}s before retry {attempt+1}/{self.retries}") + time.sleep(wait_time) + else: + _log("WARNING", f"TVDB HTTP {e.code} error on attempt {attempt+1}/{self.retries}") + except Exception as e: + _log("WARNING", f"TVDB API attempt {attempt+1}/{self.retries} failed: {e}") + + if attempt < self.retries - 1: + time.sleep(1.0 * (attempt + 1)) + + return None + + def series_by_imdb(self, imdb_id): + """Find series by IMDb ID""" + j = self._get(f"/search", {"query": imdb_id, "type": "series"}) + if not j or not j.get("data"): + return None + + # Look for exact IMDb match + for series in j["data"]: + remote_ids = series.get("remoteIds") or [] + for remote in remote_ids: + if remote.get("sourceName") == "IMDB" and remote.get("id") == imdb_id: + return series + return None + + def season_episodes(self, series_id, season_number): + """Get episodes for a specific season""" + j = self._get(f"/series/{series_id}/episodes/default", { + "season": season_number, + "page": 0 + }) + + episodes = {} + if not j or not j.get("data"): + return episodes + + for ep in j["data"]["episodes"]: + ep_num = ep.get("number") + aired = ep.get("aired") + if isinstance(ep_num, int) and aired: + episodes[ep_num] = aired + + return episodes + +class OMDbClient: + def __init__(self): + self.api_key = os.environ.get("OMDB_API_KEY", "") + self.enabled = bool(self.api_key) + + def season_airdates(self, imdb_id, season_number): + # http://www.omdbapi.com/?i=tt2085059&Season=1&apikey=KEY + if not self.enabled: return {} + params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key} + url = "http://www.omdbapi.com/?" + urllib.parse.urlencode(params) + j = http_get_json(url, timeout=45) + res = {} + if not j or j.get("Response") != "True": + return res + for ep in j.get("Episodes", []): + try: + num = int(ep.get("Episode")) + except Exception: + continue + rel = ep.get("Released") # '2011-12-04' etc + if rel and rel != "N/A": + res[num] = rel + return res + +# ========================= +# NFO helpers +# ========================= +LONG_NFO_RE = re.compile(r".*-S\d{2}E\d{2}-.*\.nfo$", re.IGNORECASE) +SHORT_NFO_RE = re.compile(r"^S(\d{2})E(\d{2})\.nfo$", re.IGNORECASE) +IMDB_TAG_RE = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE) + +def remove_long_nfos(season_dir: Path) -> int: + count = 0 + for f in season_dir.glob("*.nfo"): + if LONG_NFO_RE.match(f.name): + try: + f.unlink() + count += 1 + except Exception: + pass + return count + +def write_episode_nfo(season_dir: Path, season_num: int, ep_num: int, aired: str, dateadded: str, source: str, lock_metadata: bool = True): + # Ensure the season directory exists + try: + season_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + _log("ERROR", f"Failed creating season directory {season_dir}: {e}") + return + + name = f"S{season_num:02d}E{ep_num:02d}.nfo" + dst = season_dir / name + xml = [ + '', + "", + f" {season_num}", + f" {ep_num}", + f" {(aired or '').split('T')[0]}", + ] + if dateadded: + xml.append(f" {dateadded}") + # Add premiered tag with same value as dateadded for broader compatibility + xml.append(f" {dateadded.split('T')[0]}") + + # Add lock flag if requested (prevents Emby/Plex from overwriting metadata) + if lock_metadata: + xml.append(" true") + + # Add source comment for debugging + if source: + xml.append(f" ") + xml.append(f" ") + + xml.append("\n") + content = "\n".join(xml) + try: + dst.write_text(content, encoding="utf-8") + except Exception as e: + _log("ERROR", f"Failed writing {dst}: {e}") + +def ensure_season_nfo(season_dir: Path, season_num: int): + # Small, safe season.nfo; don't overwrite if present + p = season_dir / "season.nfo" + if p.exists(): + return + xml = f""" + + {season_num} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not write season.nfo in {season_dir}: {e}") + +def maybe_tvshow_nfo(series_dir: Path, imdb_id: str): + # Don't clobber; only create minimal file if missing + p = series_dir / "tvshow.nfo" + if p.exists(): + return + xml = f""" + + {imdb_id} + {imdb_id} + +""" + try: + p.write_text(xml, encoding="utf-8") + except Exception as e: + _log("WARNING", f"Could not create tvshow.nfo in {series_dir}: {e}") + +def set_dir_mtime(path: Path, iso_timestamp: str): + try: + ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp() + os.utime(path, (ts, ts), follow_symlinks=False) + except Exception as e: + _log("WARNING", f"Failed to set mtime on {path}: {e}") + +def set_episode_file_mtimes(video_files, dateadded_iso: str): + """Set individual episode file modification times to match the dateadded""" + try: + ts = datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp() + for video_file in video_files: + try: + os.utime(video_file, (ts, ts), follow_symlinks=False) + _log("DEBUG", f"Updated file mtime: {video_file} -> {dateadded_iso}") + except Exception as e: + _log("WARNING", f"Failed to update mtime on {video_file}: {e}") + except Exception as e: + _log("WARNING", f"Bad timestamp for file mtimes: {dateadded_iso}: {e}") + +# ========================= +# Core logic +# ========================= +def parse_imdb_from_path(p: Path): + m = IMDB_TAG_RE.search(p.name) + return m.group(1).lower() if m else None + +def is_series_dir(p: Path) -> bool: + return p.is_dir() and parse_imdb_from_path(p) is not None + +def gather_episode_dates(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection, disk_episodes: dict): + sonarr = SonarrClient() + tmdb = TMDBClient() + omdb = OMDbClient() + + # results: dict[(season, episode)] = (aired_iso, dateadded_iso, source) + out = {} + + # If using cache, first load existing data from DB (only for episodes we have on disk) + if args.use_cache: + _log("INFO", f"Checking cache for existing episode data: {imdb_id}") + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ? AND has_video_file = 1", + (imdb_id,) + ).fetchall() + + for season, episode, aired, dateadded, source in cached: + key = (season, episode) + if key in disk_episodes: # Only load cached data for episodes we have + out[key] = (aired, dateadded, source) + + _log("INFO", f"Found {len(out)} cached episodes with video files for {imdb_id}") + + # Find which disk episodes we don't have cached yet + cached_episodes = set(out.keys()) + missing_episodes = set(disk_episodes.keys()) - cached_episodes + + if missing_episodes: + _log("INFO", f"Found {len(missing_episodes)} episodes on disk not in cache: {sorted(missing_episodes)}") + else: + _log("INFO", f"All disk episodes are cached, skipping API queries") + return out + + # Only query APIs if not using cache or if we have missing episodes + need_api_query = not args.use_cache or (args.use_cache and missing_episodes) + + if need_api_query: + # Prefer Sonarr + if sonarr.enabled: + _log("INFO", f"Querying Sonarr for series: {imdb_id}") + s = sonarr.series_by_imdb(imdb_id) + + # If no match by IMDb, try title search + if not s: + series_title = series_dir.name.split('[imdb-')[0].strip() + if series_title: + _log("INFO", f"IMDb lookup failed, trying title search: {series_title}") + s = sonarr.series_by_title(series_title) + + # If still no match, try direct lookup + if not s: + _log("INFO", f"Title search failed, trying direct series scan") + s = sonarr.series_by_imdb_direct(imdb_id) + + if s: + series_id = s.get("id") + if series_id: # Check that series_id is not None + _log("INFO", f"Found Sonarr series ID: {series_id}") + eps = sonarr.episodes_for_series(series_id) + if isinstance(eps, list): + _log("INFO", f"Found {len(eps)} episodes in Sonarr") + for ep in eps: + season_num = ep.get("seasonNumber") + ep_num = ep.get("episodeNumber") + episode_id = ep.get("id") + + if not isinstance(season_num, int) or not isinstance(ep_num, int): + continue + + # Skip episodes we don't have on disk + key = (season_num, ep_num) + if key not in disk_episodes: + continue + + # If using cache, skip episodes we already have unless forced + if args.use_cache and key in out and not args.force_refresh: + continue + + _log("DEBUG", f"Processing S{season_num:02d}E{ep_num:02d} (episode ID: {episode_id})") + + air_utc = to_utc_iso(ep.get("airDateUtc")) + date_add = None + src = None + + # Try to get original import date from history first + if episode_id: + import_date = sonarr.get_episode_import_history(episode_id) + if import_date: + date_add = to_utc_iso(import_date) + src = "sonarr:history.downloadFolderImported" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using original import date {date_add}") + + # Fallback to episode file dateAdded (but validate it's reasonable) + if not date_add: + ep_file_id = ep.get("episodeFileId") + if ep_file_id: + fobj = sonarr.episode_file(ep_file_id) + if fobj and fobj.get("dateAdded"): + file_date_added = to_utc_iso(fobj.get("dateAdded")) + + # Validate: if dateAdded is way after air date, it's probably a re-import + if air_utc and file_date_added: + try: + air_dt = datetime.fromisoformat(air_utc.replace("Z", "+00:00")) + file_dt = datetime.fromisoformat(file_date_added.replace("Z", "+00:00")) + days_diff = (file_dt - air_dt).days + + # If file was added more than 180 days after air date, treat as suspicious + if days_diff > 180: + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: dateAdded ({file_date_added}) is {days_diff} days after air date - likely re-import, using air date instead") + date_add = air_utc + src = "sonarr:episode.airDateUtc" + else: + date_add = file_date_added + src = "sonarr:episodeFile.dateAdded" + _log("INFO", f"S{season_num:02d}E{ep_num:02d}: Using file dateAdded {date_add} ({days_diff} days after air)") + except Exception: + # If we can't parse dates, just use what we have + date_add = file_date_added + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: Could not validate dateAdded, using as-is {date_add}") + else: + # No air date to compare against, use dateAdded + date_add = file_date_added + src = "sonarr:episodeFile.dateAdded" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No air date for comparison, using file dateAdded {date_add}") + + # Final fallback to air date + if not date_add and air_utc: + date_add = air_utc + src = "sonarr:episode.airDateUtc" + _log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No reliable dateAdded found, using air date {date_add}") + + if air_utc or date_add: + out[key] = (air_utc, date_add, src) + else: + _log("WARNING", f"Failed to get episodes for Sonarr series ID: {series_id}") + else: + _log("WARNING", f"Sonarr series found but has no ID for IMDb: {imdb_id}") + else: + _log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}") + + # Fill gaps with TMDB (only for episodes we have on disk) + if tmdb.enabled: + tv_id = tmdb.tv_id_from_imdb(imdb_id) + if tv_id: + # Only check seasons that have video files + seasons_with_files = {sn for sn, en in disk_episodes.keys()} + for sn in sorted(seasons_with_files): + m = tmdb.season_airdates(tv_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key in disk_episodes and (key not in out or not out[key][1]): # Only for episodes we have on disk + air_iso = to_utc_iso(air) # date → midnight UTC + out[key] = (air_iso, air_iso, "tmdb:air_date") + + # Fill remaining with OMDb (only for episodes we have on disk) + if omdb.enabled: + seasons_with_files = {sn for sn, en in disk_episodes.keys()} + for sn in sorted(seasons_with_files): + m = omdb.season_airdates(imdb_id, sn) + for ep_num, air in m.items(): + key = (sn, ep_num) + if key in disk_episodes and (key not in out or not out[key][1]): # Only for episodes we have on disk + air_iso = to_utc_iso(air) + out[key] = (air_iso, air_iso, "omdb:Released") + + # Absolute last resort: file mtime (only for episodes we have on disk) + for (sn, en), video_files in disk_episodes.items(): + key = (sn, en) + if key not in out or not out[key][1]: + # Use the first video file for mtime + if video_files: + mt = file_mtime_iso(video_files[0]) + out[key] = ( (out.get(key) or (None,))[0] , mt, "file:mtime") + + return out + +def is_series_current(series_dir: Path, imdb_id: str, args, conn: sqlite3.Connection): + """Check if series NFOs and mtimes are already current with database""" + if not args.skip_if_current: + return False + + # Get cached episode data + cached = conn.execute( + "SELECT season, episode, aired, dateadded, source FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + if not cached: + return False + + # Check if NFO files match database + for season, episode, aired, dateadded, source in cached: + season_dir = series_dir / f"Season {season:02d}" + nfo_file = season_dir / f"S{season:02d}E{episode:02d}.nfo" + + if not nfo_file.exists(): + return False + + # Quick check - does NFO contain the expected dateadded? + try: + nfo_content = nfo_file.read_text(encoding="utf-8") + if dateadded and dateadded not in nfo_content: + return False + except Exception: + return False + + return True + +def apply_series(series_dir: Path, args, conn: sqlite3.Connection): + imdb_id = parse_imdb_from_path(series_dir) + if not imdb_id: + _log("ERROR", f"Series path does not contain an IMDb id [imdb-tt...] : {series_dir}") + return + + # Check if series is already current + if is_series_current(series_dir, imdb_id, args, conn): + _log("INFO", f"Series already current, skipping: {series_dir.name}") + return + + # Store/refresh series row + conn.execute( + "INSERT INTO series(imdb_id, series_path, last_applied) VALUES(?,?,?) " + "ON CONFLICT(imdb_id) DO UPDATE SET series_path=excluded.series_path, last_applied=excluded.last_applied", + (imdb_id, str(series_dir), _iso_now()), + ) + conn.commit() + + # Build a map of episodes that actually exist on disk + disk_episodes = {} # {(season, episode): [video_files]} + seasons_with_videos = set() # Track which seasons have video files + + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + + # Find video files and extract season/episode numbers + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + for f in season_dir.iterdir(): + if f.is_file() and f.suffix.lower() in video_exts: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + file_sn, file_en = int(m.group(1)), int(m.group(2)) + # Use the season number from the directory, not the filename + key = (sn, file_en) + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(f) + seasons_with_videos.add(sn) + + _log("INFO", f"Found {len(disk_episodes)} episodes on disk: {sorted(disk_episodes.keys())}") + + # Clean long-name NFOs and ensure tvshow.nfo/season.nfo if asked + if args.manage_nfo: + for season_dir in series_dir.iterdir(): + if season_dir.is_dir() and season_dir.name.lower().startswith("season "): + removed = remove_long_nfos(season_dir) + if removed: + _log("INFO", f"Removed {removed} long-name NFO(s) in {season_dir}") + + # Remove orphaned NFO files (NFOs without corresponding video files) + try: + sn = int(season_dir.name.split()[-1]) + orphaned = 0 + for nfo_file in season_dir.glob("S??E??.nfo"): + m = re.match(r"S(\d{2})E(\d{2})\.nfo$", nfo_file.name, re.IGNORECASE) + if m: + nfo_sn, nfo_en = int(m.group(1)), int(m.group(2)) + key = (sn, nfo_en) # Use directory season number + if key not in disk_episodes: + try: + nfo_file.unlink() + orphaned += 1 + _log("DEBUG", f"Removed orphaned NFO: {nfo_file}") + except Exception as e: + _log("WARNING", f"Failed to remove orphaned NFO {nfo_file}: {e}") + + if orphaned > 0: + _log("INFO", f"Removed {orphaned} orphaned NFO file(s) in {season_dir}") + + # Remove orphaned season.nfo files from empty seasons + if sn not in seasons_with_videos: + season_nfo = season_dir / "season.nfo" + if season_nfo.exists(): + try: + season_nfo.unlink() + _log("INFO", f"Removed orphaned season.nfo from empty season: {season_dir}") + except Exception as e: + _log("WARNING", f"Failed to remove orphaned season.nfo {season_nfo}: {e}") + else: + # Only create season.nfo for seasons that have video files + ensure_season_nfo(season_dir, sn) + + except Exception: + pass + maybe_tvshow_nfo(series_dir, imdb_id) + + # Gather dates only for episodes that exist on disk + all_dates = gather_episode_dates(series_dir, imdb_id, args, conn, disk_episodes) + + # Process episodes and update file mtimes individually + seasons_latest = {} + processed_count = 0 + + for (sn, en), (aired, dateadded, source) in sorted(all_dates.items()): + key = (sn, en) + + # Only process episodes that have video files on disk + if key in disk_episodes: + has_video = 1 + + # Write episode NFO only for episodes with video files + if args.manage_nfo: + write_episode_nfo( + series_dir / f"Season {sn:02d}", + sn, en, aired, dateadded, source or "", + lock_metadata=getattr(args, 'lock_metadata', True) + ) + + # Update individual episode file modification times + if args.update_video_mtimes and dateadded: + video_files = disk_episodes[key] + set_episode_file_mtimes(video_files, dateadded) + + processed_count += 1 + + # Compute latest per season for directory mtime + if dateadded: + try: + ts = datetime.fromisoformat(dateadded.replace("Z", "+00:00")) + except Exception: + ts = None + if ts: + seasons_latest.setdefault(sn, ts) + if ts > seasons_latest[sn]: + seasons_latest[sn] = ts + + # DB upsert for episodes WITH video files + conn.execute( + "INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source, has_video_file) " + "VALUES(?,?,?,?,?,?,?) " + "ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source, has_video_file=excluded.has_video_file", + (imdb_id, sn, en, aired or None, dateadded or None, source or None, has_video), + ) + + _log("INFO", f"Processed {processed_count} episodes with video files, stored {len(all_dates)} total episodes in database") + conn.commit() + + # Fix folder mtimes (season dirs) - use latest episode date per season + if args.fix_dir_mtimes and seasons_latest: + for sn, ts in seasons_latest.items(): + season_dir = series_dir / f"Season {sn:02d}" + if season_dir.exists(): + iso = ts.astimezone(timezone.utc).isoformat(timespec="seconds") + set_dir_mtime(season_dir, iso) + _log("INFO", f"Updated mtime on {season_dir} -> {iso}") + +def iterate_target_path(path: Path): + # If path is a series dir (has imdb tag), return [path]; if it's a root, return imdb-tag subdirs + if is_series_dir(path): + return [path] + if path.is_dir(): + out = [] + for p in path.iterdir(): + if is_series_dir(p): + out.append(p) + return sorted(out) + return [] + +# ========================= +# CLI +# ========================= +def cmd_cleanup_orphans(args): + """Remove database entries for episodes that no longer exist on disk""" + _log("INFO", f"Cleaning orphaned episodes from DB at {args.db}") + conn = db_connect(Path(args.db)) + + # Get all series from database + series_rows = conn.execute("SELECT imdb_id, series_path FROM series").fetchall() + + total_removed = 0 + for imdb_id, series_path in series_rows: + series_dir = Path(series_path) + if not series_dir.exists(): + _log("WARNING", f"Series directory no longer exists: {series_path}") + continue + + # Build map of episodes that actually exist on disk + disk_episodes = {} + for season_dir in series_dir.iterdir(): + if not (season_dir.is_dir() and season_dir.name.lower().startswith("season ")): + continue + try: + sn = int(season_dir.name.split()[-1]) + except Exception: + continue + + # Find video files + video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") + for f in season_dir.iterdir(): + if f.is_file() and f.suffix.lower() in video_exts: + m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE) + if m: + file_sn, file_en = int(m.group(1)), int(m.group(2)) + key = (sn, file_en) + if key not in disk_episodes: + disk_episodes[key] = [] + disk_episodes[key].append(f) + + # Get all episodes for this series from database + db_episodes = conn.execute( + "SELECT season, episode FROM episode_dates WHERE imdb_id = ?", + (imdb_id,) + ).fetchall() + + # Find orphaned episodes (in DB but not on disk) + orphaned = [] + for season, episode in db_episodes: + if (season, episode) not in disk_episodes: + orphaned.append((season, episode)) + + if orphaned: + _log("INFO", f"Removing {len(orphaned)} orphaned episodes for {series_dir.name}") + for season, episode in orphaned: + conn.execute( + "DELETE FROM episode_dates WHERE imdb_id = ? AND season = ? AND episode = ?", + (imdb_id, season, episode) + ) + _log("DEBUG", f"Removed S{season:02d}E{episode:02d} from database") + total_removed += len(orphaned) + else: + _log("DEBUG", f"No orphaned episodes found for {series_dir.name}") + + conn.commit() + _log("INFO", f"Cleanup complete: removed {total_removed} orphaned episode entries") + conn.close() + +def cmd_rebuild_db(args): + _log("INFO", f"Rebuilding DB at {args.db}") + conn = db_connect(Path(args.db)) + # wipe content but keep schema + conn.execute("DELETE FROM episode_dates;") + conn.execute("DELETE FROM series;") + conn.commit() + conn.close() + +def cmd_apply(args): + # Enable debug logging if requested + if args.debug: + _log.debug_enabled = True + + conn = db_connect(Path(args.db)) + targets = iterate_target_path(Path(args.path)) + if not targets: + _log("ERROR", f"No series found at {args.path} (expects dirs named like 'Show Name [imdb-ttxxxxxxx]')") + return + # Inform about source availability + if not (os.environ.get("SONARR_URL") and (os.environ.get("SONARR_API_KEY") or os.environ.get("SONARR_APIKEY"))): + _log("INFO", "Sonarr not configured (set SONARR_URL and SONARR_API_KEY)") + if not os.environ.get("TVDB_API_KEY"): + _log("INFO", "TVDB not configured (set TVDB_API_KEY for better air date coverage)") + + for series_dir in targets: + _log("INFO", f"=== APPLY: {series_dir} ===") + try: + apply_series(series_dir, args, conn) + except KeyboardInterrupt: + raise + except urllib.error.HTTPError as e: + _log("ERROR", f"API error for {series_dir}: HTTP {e.code} - {e.reason}") + if e.code == 401: + _log("ERROR", "Check your API keys (SONARR_API_KEY, TMDB_API_KEY, OMDB_API_KEY)") + continue # Skip this series but continue with others + except Exception as e: + _log("ERROR", f"Failed applying {series_dir}: {e}") + continue # Skip this series but continue with others + + conn.close() + +def build_parser(): + p = argparse.ArgumentParser(prog="media_date_cache.py") + p.add_argument("--env", help="Path to .env file (can appear anywhere on the command)", default=None) + sub = p.add_subparsers(dest="cmd", required=True) + + p_rebuild = sub.add_parser("rebuild-db", help="Recreate (wipe) episode cache DB") + p_rebuild.add_argument("--db", required=True, help="Path to sqlite DB") + p_rebuild.set_defaults(func=cmd_rebuild_db) + + p_cleanup = sub.add_parser("cleanup-orphans", help="Remove database entries for episodes that no longer exist on disk") + p_cleanup.add_argument("--db", required=True, help="Path to sqlite DB") + p_cleanup.set_defaults(func=cmd_cleanup_orphans) + + p_apply = sub.add_parser("apply", help="Apply dates → NFO + mtimes for a series dir or a library root") + p_apply.add_argument("--db", required=True, help="Path to sqlite DB") + p_apply.add_argument("--path", required=True, help="Series dir (with [imdb-tt...]) or a root containing them") + p_apply.add_argument("--manage-nfo", action="store_true", help="Create short NFOs; remove long-name NFOs; ensure season.nfo/tvshow.nfo") + p_apply.add_argument("--fix-dir-mtimes", action="store_true", help="Set Season folder mtimes to latest episode 'dateadded'") + p_apply.add_argument("--update-video-mtimes", action="store_true", help="Also set video/subtitle file mtimes to match individual episode dateadded") + p_apply.add_argument("--lock-metadata", action="store_true", default=True, help="Add lockdata flag to NFO files to prevent media server overwrites") + p_apply.add_argument("--no-lock-metadata", dest="lock_metadata", action="store_false", help="Don't add lockdata flag to NFO files") + p_apply.add_argument("--skip-if-current", action="store_true", help="Skip series where NFOs are already current with database") + p_apply.add_argument("--use-cache", action="store_true", help="Use cached episode data when available, only query APIs for missing episodes") + p_apply.add_argument("--force-refresh", action="store_true", help="Force refresh of cached data (use with --use-cache)") + p_apply.add_argument("--debug", action="store_true", help="Enable debug logging") + p_apply.set_defaults(func=cmd_apply) + + return p + +def main(): + parser = build_parser() + args = parser.parse_args() + # (No need to load env here; it was already handled in preparse) + args.func(args) + +if __name__ == "__main__": + main()