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()