#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
media_date_cache.py
Applies canonical "date added" timestamps to TV and Movie files (and their sidecars),
using Sonarr/Radarr history and episode/movie metadata, with TMDB fallbacks.
Key behavior:
- Prefer original import date from Sonarr/Radarr; fallback to airdate (TV) or TMDB release date (Movie); fallback to existing mtime with --live-on-miss
- Writes minimal NFOs (TV: SxxEyy.nfo; Movie: movie.nfo)
- **NFO mtime always mirrors the video file's actual mtime** after processing (so it matches the file + sidecars)
- Sidecars (.srt/.ass/.vtt/...) are aligned to the chosen timestamp (or left alone with --only-older)
- SQLite cache to avoid re-fetching unless --refresh
- Gotify notifications (optional)
"""
from __future__ import annotations
import argparse
import dataclasses
from dataclasses import field
import datetime as dt
import json
import os
import re
import sqlite3
import sys
import time
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Tuple
import urllib.request
import urllib.error
DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "")
DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "")
SONARR_TIMEOUT = int(os.getenv("SONARR_TIMEOUT", "45"))
SONARR_RETRIES = int(os.getenv("SONARR_RETRIES", "3"))
RADARR_TIMEOUT = int(os.getenv("RADARR_TIMEOUT", "45"))
RADARR_RETRIES = int(os.getenv("RADARR_RETRIES", "3"))
TMDB_TIMEOUT = int(os.getenv("TMDB_TIMEOUT", "45"))
TMDB_RETRIES = int(os.getenv("TMDB_RETRIES", "3"))
LOG_TS_FMT = "%Y-%m-%d %H:%M:%S"
VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm"}
SIDECAR_EXTS = {".srt", ".sub", ".idx", ".ass", ".ssa", ".vtt", ".smi", ".rt", ".txt"}
NFO_SIMPLE_PATTERN = re.compile(r"^S\d{2}E\d{2}\.nfo$", re.IGNORECASE)
SE_EP_RX = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})")
IMDB_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE)
TMDB_RX = re.compile(r"\[tmdb-(\d+)\]", re.IGNORECASE)
YEAR_RX = re.compile(r"\((\d{4})\)")
DEBUG_HTTP = False
DEBUG_TRUNCATE = 3000 # chars
def log(msg: str) -> None:
ts = dt.datetime.now().strftime(LOG_TS_FMT)
print(f"[{ts}] {msg}", flush=True)
def dlog(msg: str) -> None:
if DEBUG_HTTP:
log(f"[DEBUG] {msg}")
def send_gotify_notification(title: str, message: str, priority: int = 5) -> None:
if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN:
return
try:
url = f"{DEFAULT_GOTIFY_URL.rstrip('/')}/message"
data = json.dumps({"title": title, "message": message, "priority": priority}).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-Gotify-Key", DEFAULT_GOTIFY_TOKEN)
with urllib.request.urlopen(req, timeout=15):
pass
except Exception as e:
log(f"WARN: Gotify notification failed: {e}")
def load_env_file(env_path: Optional[str]) -> None:
if not env_path:
return
p = Path(env_path)
if not p.exists():
log(f"WARN: .env not found: {env_path}")
return
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
os.environ[k] = v
# ---------------- DB ----------------
def db_init(cx: sqlite3.Connection) -> None:
cx.execute(
"""
CREATE TABLE IF NOT EXISTS media_dates(
path TEXT PRIMARY KEY,
kind TEXT NOT NULL,
chosen_ts INTEGER NOT NULL,
chosen_src TEXT NOT NULL,
extra TEXT,
last_updated INTEGER DEFAULT (strftime('%s', 'now'))
)
"""
)
cx.commit()
def db_get_cached(cx: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]:
cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,))
row = cur.fetchone()
return (int(row[0]), row[1]) if row else None
def db_upsert(cx: sqlite3.Connection, path: str, kind: str, chosen_ts: int, chosen_src: str, extra: Dict[str, object] | None) -> None:
cx.execute(
"""
INSERT INTO media_dates(path, kind, chosen_ts, chosen_src, extra, last_updated)
VALUES(?,?,?,?,?,?)
ON CONFLICT(path) DO UPDATE SET
kind=excluded.kind,
chosen_ts=excluded.chosen_ts,
chosen_src=excluded.chosen_src,
extra=excluded.extra,
last_updated=excluded.last_updated
""",
(path, kind, int(chosen_ts), chosen_src, json.dumps(extra or {}, ensure_ascii=False), int(time.time())),
)
cx.commit()
# -------------- HTTP helper --------------
def _pretty(obj) -> str:
try:
return json.dumps(obj, indent=2, ensure_ascii=False)[:DEBUG_TRUNCATE]
except Exception:
return str(obj)[:DEBUG_TRUNCATE]
def _req_json(method: str, url: str, *, headers: Optional[Dict[str, str]] = None, params: Optional[Dict[str, object]] = None, timeout: int = 30, retries: int = 2) -> dict | list:
from urllib.parse import urlencode
full_url = f"{url}?{urlencode(params)}" if params else url
last_err = None
for _ in range(max(1, retries)):
try:
dlog(f"HTTP {method} {full_url}")
req = urllib.request.Request(full_url, method=method)
for k, v in (headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
txt = body.decode("utf-8", errors="ignore")
try:
js = json.loads(txt)
dlog(f"← {full_url} OK\n{_pretty(js)}")
return js
except Exception as je:
dlog(f"← {full_url} NON-JSON ({len(txt)} bytes)\n{txt[:DEBUG_TRUNCATE]}")
raise RuntimeError(f"Non-JSON response from {full_url}") from je
except urllib.error.HTTPError as e:
last_err = e
err_body = e.read().decode("utf-8", errors="ignore")
dlog(f"← {full_url} HTTP {e.code}\n{err_body[:DEBUG_TRUNCATE]}")
time.sleep(0.35)
except Exception as e:
last_err = e
dlog(f"← {full_url} ERROR: {e}")
time.sleep(0.35)
raise last_err or RuntimeError("request failed")
# ---------- API clients + caches ----------
@dataclasses.dataclass
class SonarrClient:
url: str
api_key: str
timeout: int = SONARR_TIMEOUT
retries: int = SONARR_RETRIES
def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key}
def ping(self) -> bool:
try:
_req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries)
return True
except Exception as e:
log(f"WARN: Sonarr ping failed: {e}")
return False
def series_index(self) -> List[dict]:
res = _req_json("GET", f"{self.url}/api/v3/series", headers=self._h(), timeout=self.timeout, retries=self.retries)
return res if isinstance(res, list) else list(res or [])
def history_for(self, series_id: int) -> List[dict]:
res = _req_json("GET", f"{self.url}/api/v3/history/series", headers=self._h(), params={"seriesId": series_id, "pageSize": 1000}, timeout=self.timeout, retries=self.retries)
if isinstance(res, dict):
recs = res.get("records")
return recs if isinstance(recs, list) else []
if isinstance(res, list):
return res
return []
def episodes_for(self, series_id: int) -> List[dict]:
res = _req_json("GET", f"{self.url}/api/v3/episode", headers=self._h(), params={"seriesId": series_id}, timeout=self.timeout, retries=self.retries)
return res if isinstance(res, list) else []
@dataclasses.dataclass
class SonarrCache:
sc: SonarrClient
episodes_map: Dict[int, List[dict]] = field(default_factory=dict)
history_map: Dict[int, List[dict]] = field(default_factory=dict)
def episodes(self, series_id: int) -> List[dict]:
if series_id not in self.episodes_map:
eps = self.sc.episodes_for(series_id)
self.episodes_map[series_id] = eps
dlog(f"Cached Sonarr episodes for series {series_id}: {len(eps)}")
return self.episodes_map[series_id]
def history(self, series_id: int) -> List[dict]:
if series_id not in self.history_map:
hist = self.sc.history_for(series_id)
self.history_map[series_id] = hist
dlog(f"Cached Sonarr history for series {series_id}: {len(hist)}")
return self.history_map[series_id]
@dataclasses.dataclass
class RadarrClient:
url: str
api_key: str
timeout: int = RADARR_TIMEOUT
retries: int = RADARR_RETRIES
def _h(self) -> Dict[str, str]: return {"X-Api-Key": self.api_key}
def ping(self) -> bool:
try:
_req_json("GET", f"{self.url}/api/v3/system/status", headers=self._h(), timeout=self.timeout, retries=self.retries)
return True
except Exception as e:
log(f"WARN: Radarr ping failed: {e}")
return False
def movies_index(self) -> List[dict]:
res = _req_json("GET", f"{self.url}/api/v3/movie", headers=self._h(), timeout=self.timeout, retries=self.retries)
return res if isinstance(res, list) else []
def history_for(self, movie_id: int) -> List[dict]:
res = _req_json("GET", f"{self.url}/api/v3/history/movie", headers=self._h(), params={"movieId": movie_id, "pageSize": 1000}, timeout=self.timeout, retries=self.retries)
if isinstance(res, dict):
recs = res.get("records")
return recs if isinstance(recs, list) else []
if isinstance(res, list):
return res
return []
@dataclasses.dataclass
class TMDBClient:
api_key: str
primary_country: str = "US"
base_url: str = "https://api.themoviedb.org/3"
timeout: int = TMDB_TIMEOUT
retries: int = TMDB_RETRIES
def _p(self, **kw) -> Dict[str, object]:
p = {"api_key": self.api_key, "language": "en-US"}; p.update(kw); return p
def find_tv_by_imdb(self, imdb_id: str) -> Optional[int]:
js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries)
lst = js.get("tv_results") if isinstance(js, dict) else None
return lst[0].get("id") if lst else None
def search_tv(self, name: str, year: Optional[int]) -> Optional[int]:
js = _req_json("GET", f"{self.base_url}/search/tv", params=self._p(query=name, first_air_date_year=year or ""), timeout=self.timeout, retries=self.retries)
res = js.get("results") if isinstance(js, dict) else None
return res[0].get("id") if res else None
def find_movie_by_imdb(self, imdb_id: str) -> Optional[int]:
js = _req_json("GET", f"{self.base_url}/find/{imdb_id}", params=self._p(external_source="imdb_id"), timeout=self.timeout, retries=self.retries)
lst = js.get("movie_results") if isinstance(js, dict) else None
return lst[0].get("id") if lst else None
def search_movie(self, name: str, year: Optional[int]) -> Optional[int]:
js = _req_json("GET", f"{self.base_url}/search/movie", params=self._p(query=name, year=year or "", primary_release_year=year or ""), timeout=self.timeout, retries=self.retries)
res = js.get("results") if isinstance(js, dict) else None
return res[0].get("id") if res else None
def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]:
try:
js = _req_json("GET", f"{self.base_url}/tv/{tv_id}/season/{season}/episode/{episode}", params=self._p(), timeout=self.timeout, retries=self.retries)
return js.get("air_date") if isinstance(js, dict) else None
except Exception:
return None
def movie_release_date(self, movie_id: int) -> Optional[str]:
try:
js = _req_json("GET", f"{self.base_url}/movie/{movie_id}", params=self._p(), timeout=self.timeout, retries=self.retries)
return js.get("release_date") if isinstance(js, dict) else None
except Exception:
return None
# -------------- parsers --------------
@dataclasses.dataclass
class EpisodeKey:
series_title: str
season: int
episode: int
imdb_id: Optional[str]
tmdb_id: Optional[str]
year: Optional[int]
def parse_episode_from_path(p: Path) -> Optional[EpisodeKey]:
parent = p.parent
show_dir = parent.parent.name if parent.name.lower().startswith("season ") else parent.name
m = SE_EP_RX.search(p.name)
if not m:
return None
season = int(m.group(1)); episode = int(m.group(2))
imdb_id_match = IMDB_RX.search(p.name) or IMDB_RX.search(show_dir)
imdb_id = imdb_id_match.group(1) if imdb_id_match else None
tmdb_id_match = TMDB_RX.search(p.name) or TMDB_RX.search(show_dir)
tmdb_id = tmdb_id_match.group(1) if tmdb_id_match else None
year = None
m3 = YEAR_RX.search(show_dir)
if m3:
try:
year = int(m3.group(1))
except Exception:
year = None
series_title = IMDB_RX.sub("", show_dir)
series_title = TMDB_RX.sub("", series_title)
series_title = YEAR_RX.sub("", series_title).strip() or show_dir
return EpisodeKey(series_title, season, episode, imdb_id, tmdb_id, year)
@dataclasses.dataclass
class MovieKey:
title: str
year: Optional[int]
imdb_id: Optional[str]
tmdb_id: Optional[str]
def parse_movie_from_path(p: Path) -> Optional[MovieKey]:
movie_dir = p.parent.name
imdb_id_match = IMDB_RX.search(movie_dir)
tmdb_id_match = TMDB_RX.search(movie_dir)
imdb_id = imdb_id_match.group(1) if imdb_id_match else None
tmdb_id = tmdb_id_match.group(1) if tmdb_id_match else None
year = None
m3 = YEAR_RX.search(movie_dir)
if m3:
try:
year = int(m3.group(1))
except Exception:
year = None
clean = IMDB_RX.sub("", movie_dir)
clean = TMDB_RX.sub("", clean)
clean = YEAR_RX.sub("", clean).strip() or movie_dir
return MovieKey(clean, year, imdb_id, tmdb_id)
# -------------- date picking --------------
def parse_date_yyyy_mm_dd(s: str) -> Optional[int]:
try:
y, m, d = map(int, s.split("-"))
return int(dt.datetime(y, m, d, 0, 0, 0, tzinfo=dt.timezone.utc).timestamp())
except Exception:
return None
def pick_from_sonarr_import_for_episode_from_history(history: List[dict], episode_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]:
import_types = {"episodeFileImported", "downloadFolderImported", "manualImport"}
grabbed_types = {"grabbed"}
imports: List[Tuple[int, dict]] = []
grabs: List[Tuple[int, dict]] = []
for rec in history or []:
if not isinstance(rec, dict):
continue
if int(rec.get("episodeId", -1)) != int(episode_id):
continue
et = rec.get("eventType") or ""
date_s = rec.get("date")
if not date_s:
continue
try:
t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00"))
ts = int(t.timestamp())
except Exception:
continue
if et in import_types:
imports.append((ts, rec))
elif et in grabbed_types:
grabs.append((ts, rec))
if imports:
ts, rec0 = sorted(imports, key=lambda x: x[0])[0]
return ts, "sonarr_import", {"episodeId": episode_id, "eventType": rec0.get("eventType")}
if grabs:
ts, rec0 = sorted(grabs, key=lambda x: x[0])[0]
return ts, "sonarr_grabbed", {"episodeId": episode_id, "eventType": rec0.get("eventType")}
return None
def pick_from_sonarr_episode_from_list(episodes: List[dict], season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]:
for ep in episodes or []:
if not isinstance(ep, dict):
continue
if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode:
air = ep.get("airDateUtc") or ep.get("airDate")
if not air:
return None
if "T" in air:
try:
t = dt.datetime.fromisoformat(air.replace("Z", "+00:00"))
return int(t.timestamp()), "sonarr_airdate", {"episodeId": ep.get("id")}
except Exception:
return None
ts = parse_date_yyyy_mm_dd(air)
if ts is not None:
return ts, "sonarr_airdate", {"episodeId": ep.get("id")}
return None
return None
def pick_from_radarr_history(rc: RadarrClient, movie_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]:
try:
hist = rc.history_for(movie_id)
except Exception as e:
log(f"WARN: Radarr history timeout/err movie={movie_id}: {e}")
return None
best_ts = None; best = None
for rec in hist or []:
if not isinstance(rec, dict):
continue
date_s = rec.get("date") or ""
if not date_s:
continue
try:
t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00"))
ts = int(t.timestamp())
except Exception:
continue
if best_ts is None or ts < best_ts:
best_ts = ts; best = rec
if best_ts is not None:
return best_ts, "radarr_history", {"history_id": (best or {}).get("id")}
return None
@dataclasses.dataclass
class TMDBMovieLike:
title: str; year: Optional[int]; imdb_id: Optional[str]; tmdb_id: Optional[str]
def pick_from_tmdb_movie(tmdb: TMDBClient, mk: TMDBMovieLike) -> Optional[Tuple[int, str, Dict[str, object]]]:
if not tmdb.api_key:
return None
movie_id = None
if mk.tmdb_id:
try:
movie_id = int(mk.tmdb_id)
except Exception:
movie_id = None
if movie_id is None and mk.imdb_id:
try:
movie_id = tmdb.find_movie_by_imdb(mk.imdb_id)
except Exception as e:
log(f"WARN: TMDB find movie by IMDb failed {mk.imdb_id}: {e}")
if movie_id is None:
try:
movie_id = tmdb.search_movie(mk.title, mk.year)
except Exception as e:
log(f"WARN: TMDB movie search failed title={mk.title}: {e}")
if movie_id is None:
return None
rel = tmdb.movie_release_date(movie_id)
if rel:
ts = parse_date_yyyy_mm_dd(rel)
if ts is not None:
return ts, "tmdb_movie_release", {"tmdb_movie_id": movie_id, "release_date": rel}
return None
def pick_from_tmdb_tv(tmdb: TMDBClient, series_title: str, season: int, episode: int, imdb_id: Optional[str], tmdb_id: Optional[str], year: Optional[int]) -> Optional[Tuple[int, str, Dict[str, object]]]:
if not tmdb.api_key:
return None
tv_id = None
if tmdb_id:
try:
tv_id = int(tmdb_id)
except Exception:
tv_id = None
if tv_id is None and imdb_id:
try:
tv_id = tmdb.find_tv_by_imdb(imdb_id)
except Exception as e:
log(f"WARN: TMDB find by IMDb failed {imdb_id}: {e}")
if tv_id is None:
try:
tv_id = tmdb.search_tv(series_title, year)
except Exception as e:
log(f"WARN: TMDB search failed title={series_title}: {e}")
if tv_id is None:
return None
air = tmdb.episode_air_date(tv_id, season, episode)
if air:
ts = parse_date_yyyy_mm_dd(air)
if ts is not None:
return ts, "tmdb_episode_airdate", {"tmdb_tv_id": tv_id, "air_date": air}
return None
# -------------- NFO helpers --------------
def create_simple_nfo(season: int, episode: int, air_date: Optional[str], date_added: Optional[str]) -> str:
aired_tag = f"