#!/opt/scripts/unmanic/.venv/bin/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,
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.connect(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;")
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
j = self._get("/series/lookup", {"term": f"imdbid:{imdb_id}"})
if not j: return None
# pick exact imdb id match
for s in j:
if (s.get("imdbId") or "").lower() == imdb_id.lower():
return s
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
if import_events:
earliest = min(import_events, key=lambda x: x["date"] or "9999-12-31")
_log("INFO", f"Found original import date: {earliest['date']} (downloadFolderImported) for episode {episode_id}")
return earliest["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):
# 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}")
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_media_mtimes(season_dir: Path, iso_timestamp: str):
try:
ts = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")).timestamp()
except Exception as e:
_log("WARNING", f"Bad timestamp for media mtimes in {season_dir}: {e}")
return
exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".srt", ".ass", ".vtt")
for f in season_dir.iterdir():
if f.suffix.lower() in exts:
try:
os.utime(f, (ts, ts), follow_symlinks=False)
except Exception:
pass
# =========================
# 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):
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
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 = ?",
(imdb_id,)
).fetchall()
for season, episode, aired, dateadded, source in cached:
out[(season, episode)] = (aired, dateadded, source)
_log("INFO", f"Found {len(cached)} cached episodes for {imdb_id}")
# Find which episodes we have on disk but not in cache
disk_episodes = set()
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 episode files
media = [p for p in season_dir.iterdir()
if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)]
for f in media:
m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE)
if m:
sn2, en2 = int(m.group(1)), int(m.group(2))
if sn2 == sn:
disk_episodes.add((sn, en2))
cached_episodes = set(out.keys())
missing_episodes = disk_episodes - 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 s:
series_id = s.get("id")
_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
# If using cache, skip episodes we already have unless forced
key = (season_num, ep_num)
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
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"):
date_add = to_utc_iso(fobj.get("dateAdded"))
src = "sonarr:episodeFile.dateAdded"
_log("WARNING", f"S{season_num:02d}E{ep_num:02d}: No history found, 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 dateAdded found, using air date {date_add}")
if air_utc or date_add:
out[key] = (air_utc, date_add, src)
else:
_log("WARNING", f"Series not found in Sonarr for IMDb ID: {imdb_id}")
# Fill gaps with TMDB (only for missing episodes if using cache)
if tmdb.enabled:
tv_id = tmdb.tv_id_from_imdb(imdb_id)
if tv_id:
# find all season folders we have
seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir()
if p.is_dir() and p.name.lower().startswith("season ")
and p.name.split()[-1].isdigit()})
for sn in seasons:
m = tmdb.season_airdates(tv_id, sn)
for ep_num, air in m.items():
key = (sn, ep_num)
if key not in out or not out[key][1]: # no dateadded yet
air_iso = to_utc_iso(air) # date → midnight UTC
out[key] = (air_iso, air_iso, "tmdb:air_date")
# Fill remaining with OMDb (only for missing episodes if using cache)
if omdb.enabled:
# infer seasons as above
seasons = sorted({int(p.name.split()[-1]) for p in series_dir.iterdir()
if p.is_dir() and p.name.lower().startswith("season ")
and p.name.split()[-1].isdigit()})
for sn in seasons:
m = omdb.season_airdates(imdb_id, sn)
for ep_num, air in m.items():
key = (sn, ep_num)
if key not in out or not out[key][1]:
air_iso = to_utc_iso(air)
out[key] = (air_iso, air_iso, "omdb:Released")
# Absolute last resort: file mtime (always check for missing 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
# map existing episode files to ep numbers
media = [p for p in season_dir.iterdir()
if p.is_file() and re.search(r"S(\d{2})E(\d{2})", p.name, re.IGNORECASE)]
for f in media:
m = re.search(r"S(\d{2})E(\d{2})", f.name, re.IGNORECASE)
if not m: continue
sn2, en2 = int(m.group(1)), int(m.group(2))
if sn2 != sn: # skip cross-labeling
continue
key = (sn, en2)
if key not in out or not out[key][1]:
mt = file_mtime_iso(f)
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()
# 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}")
try:
sn = int(season_dir.name.split()[-1])
ensure_season_nfo(season_dir, sn)
except Exception:
pass
maybe_tvshow_nfo(series_dir, imdb_id)
# Gather dates
dates = gather_episode_dates(series_dir, imdb_id, args, conn)
# Write short NFOs and record DB rows; track per-season latest date for mtime
seasons_latest = {}
for (sn, en), (aired, dateadded, source) in sorted(dates.items()):
# write episode nfo
if args.manage_nfo:
write_episode_nfo(series_dir / f"Season {sn:02d}", sn, en, aired, dateadded, source or "")
# DB upsert
conn.execute(
"INSERT INTO episode_dates(imdb_id, season, episode, aired, dateadded, source) "
"VALUES(?,?,?,?,?,?) "
"ON CONFLICT(imdb_id, season, episode) DO UPDATE SET aired=excluded.aired, dateadded=excluded.dateadded, source=excluded.source",
(imdb_id, sn, en, aired or None, dateadded or None, source or None),
)
# compute latest per season for 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
conn.commit()
# Fix folder mtimes (season dirs)
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}")
if args.update_video_mtimes:
set_media_mtimes(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_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("TMDB_API_KEY"):
_log("INFO", "TMDB not configured (set TMDB_API_KEY)")
if not os.environ.get("TVDB_API_KEY"):
_log("INFO", "TVDB not configured (set TVDB_API_KEY)")
if not os.environ.get("OMDB_API_KEY"):
_log("INFO", "OMDb not configured (set OMDB_API_KEY)")
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_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 season latest (use with care)")
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()