#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Apply canonical "date" timestamps (mtime) to media files and cache results.
Priority (TV):
1) Sonarr history/import date (first successful import/release)
2) Sonarr episode 'airDate' (UTC midnight)
3) TMDB episode 'air_date'
4) (optional, with --live-on-miss) keep existing mtime
Priority (Movies):
1) Radarr history/import date (first successful import/release)
2) Radarr movie 'digitalRelease' or 'physicalRelease'
3) TMDB movie 'release_date'
4) (optional, with --live-on-miss) keep existing mtime
DB schema (SQLite):
CREATE TABLE IF NOT EXISTS media_dates(
path TEXT PRIMARY KEY,
kind TEXT NOT NULL,
chosen_ts INTEGER NOT NULL,
chosen_src TEXT NOT NULL,
extra TEXT,
last_updated INTEGER DEFAULT (strftime('%s', 'now'))
);
Usage (examples):
python media_date_cache.py apply \
--env /opt/scripts/unmanic/.env \
--kind tv \
--db /opt/scripts/unmanic/.cache/media_dates.db \
--root "/mnt/unionfs/Media/TV" \
--refresh --live-on-miss
python media_date_cache.py apply \
--env /opt/scripts/unmanic/.env \
--kind movie \
--db /opt/scripts/unmanic/.cache/media_dates.db \
--root "/mnt/unionfs/Media/Movies" \
--refresh --live-on-miss
"""
from __future__ import annotations
import argparse
import dataclasses
import datetime as dt
import json
import os
import re
import sqlite3
import sys
import time
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
import requests
# =========================
# Defaults / ENV variables
# =========================
DEFAULT_SONARR_URL = os.getenv("SONARR_URL", "http://sonarr:8989")
DEFAULT_SONARR_API_KEY = os.getenv("SONARR_API_KEY", "")
DEFAULT_RADARR_URL = os.getenv("RADARR_URL", "http://radarr:7878")
DEFAULT_RADARR_API_KEY = os.getenv("RADARR_API_KEY", "")
DEFAULT_TMDB_API_KEY = os.getenv("TMDB_API_KEY", "")
DEFAULT_TMDB_PRIMARY_COUNTRY = os.getenv("TMDB_PRIMARY_COUNTRY", "US")
DEFAULT_GOTIFY_URL = os.getenv("GOTIFY_URL", "")
DEFAULT_GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN", "")
# Multi-root support from environment
TV_ROOTS = os.getenv("TV_ROOTS", "").split(":") if os.getenv("TV_ROOTS") else []
MOVIE_ROOTS = os.getenv("MOVIE_ROOTS", "").split(":") if os.getenv("MOVIE_ROOTS") else []
SONARR_TIMEOUT = int(os.getenv("SONARR_TIMEOUT", "45"))
SONARR_RETRIES = int(os.getenv("SONARR_RETRIES", "3"))
RADARR_TIMEOUT = int(os.getenv("RADARR_TIMEOUT", "45"))
RADARR_RETRIES = int(os.getenv("RADARR_RETRIES", "3"))
TMDB_TIMEOUT = int(os.getenv("TMDB_TIMEOUT", "45"))
TMDB_RETRIES = int(os.getenv("TMDB_RETRIES", "3"))
LOG_TS_FMT = "%Y-%m-%d %H:%M:%S"
VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm"}
SIDECAR_EXTS = {".srt", ".sub", ".idx", ".ass", ".ssa", ".vtt", ".smi", ".rt", ".txt"}
NFO_SIMPLE_PATTERN = re.compile(r"^S\d{2}E\d{2}\.nfo$", re.IGNORECASE)
# File parsing patterns
SE_EP_RX = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})")
IMDB_RX = re.compile(r"\[imdb-(tt\d+)\]", re.IGNORECASE)
TMDB_RX = re.compile(r"\[tmdb-(\d+)\]", re.IGNORECASE)
YEAR_RX = re.compile(r"\((\d{4})\)")
def log(msg: str) -> None:
ts = dt.datetime.now().strftime(LOG_TS_FMT)
print(f"[{ts}] {msg}", flush=True)
def send_gotify_notification(title: str, message: str, priority: int = 5) -> None:
"""Send notification to Gotify server if configured."""
if not DEFAULT_GOTIFY_URL or not DEFAULT_GOTIFY_TOKEN:
return
try:
requests.post(
f"{DEFAULT_GOTIFY_URL}/message",
data={
"title": title,
"message": message,
"priority": priority
},
headers={"X-Gotify-Key": DEFAULT_GOTIFY_TOKEN},
timeout=10
)
except Exception as e:
log(f"WARN: Gotify notification failed: {e}")
def load_env_file(path: Optional[str]) -> None:
"""Load a simple KEY=VALUE .env file."""
if not path:
return
p = Path(path)
if not p.is_file():
log(f"WARN: .env file not found: {path}")
return
log(f"Loading environment from: {path}")
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip()
# Strip optional surrounding quotes
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
v = v[1:-1]
os.environ.setdefault(k, v)
# =================
# SQLite helpers
# =================
def db_init(cx: sqlite3.Connection) -> None:
cx.execute(
"""
CREATE TABLE IF NOT EXISTS media_dates(
path TEXT PRIMARY KEY,
kind TEXT NOT NULL,
chosen_ts INTEGER NOT NULL,
chosen_src TEXT NOT NULL,
extra TEXT,
last_updated INTEGER DEFAULT (strftime('%s', 'now'))
)
"""
)
cx.commit()
def db_get_cached(cx: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]:
"""Get cached timestamp and source for a path."""
cur = cx.execute("SELECT chosen_ts, chosen_src FROM media_dates WHERE path = ?", (path,))
row = cur.fetchone()
if row:
return row[0], row[1]
return None
def db_upsert(
cx: sqlite3.Connection,
path: str,
kind: str,
chosen_ts: int,
chosen_src: str,
extra: Dict[str, object] | None,
) -> None:
cx.execute(
"""
INSERT INTO media_dates(path, kind, chosen_ts, chosen_src, extra, last_updated)
VALUES(?,?,?,?,?,?)
ON CONFLICT(path) DO UPDATE SET
kind=excluded.kind,
chosen_ts=excluded.chosen_ts,
chosen_src=excluded.chosen_src,
extra=excluded.extra,
last_updated=excluded.last_updated
""",
(path, kind, int(chosen_ts), chosen_src, json.dumps(extra or {}, ensure_ascii=False), int(time.time())),
)
cx.commit()
# =================
# HTTP helpers
# =================
def _req_json(
method: str,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
params: Optional[Dict[str, object]] = None,
timeout: int = 30,
retries: int = 3,
backoff: float = 2.0,
) -> dict:
"""Request JSON with retries/backoff; raise on final failure."""
last_err = None
for attempt in range(1, retries + 1):
try:
r = requests.request(method, url, headers=headers or {}, params=params or {}, timeout=timeout)
r.raise_for_status()
return r.json()
except (requests.exceptions.Timeout, requests.exceptions.ReadTimeout) as e:
last_err = e
except requests.exceptions.RequestException as e:
last_err = e
if attempt < retries:
time.sleep(backoff ** attempt)
raise last_err # type: ignore[misc]
# =================
# Sonarr client
# =================
@dataclasses.dataclass
class SonarrClient:
url: str
api_key: str
timeout: int = SONARR_TIMEOUT
retries: int = SONARR_RETRIES
def _h(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key}
def ping(self) -> bool:
try:
_ = _req_json(
"GET", f"{self.url}/api/v3/system/status",
headers=self._h(), timeout=self.timeout, retries=self.retries
)
return True
except Exception as e:
log(f"WARN: Sonarr ping failed: {e}")
return False
def series_index(self) -> List[dict]:
return _req_json(
"GET", f"{self.url}/api/v3/series",
headers=self._h(), timeout=self.timeout, retries=self.retries
)
def episodes_for(self, series_id: int) -> List[dict]:
return _req_json(
"GET", f"{self.url}/api/v3/episode",
headers=self._h(), params={"seriesId": series_id},
timeout=self.timeout, retries=self.retries
)
def history_for(self, series_id: int) -> List[dict]:
res = _req_json(
"GET", f"{self.url}/api/v3/history",
headers=self._h(), params={
"page": 1,
"pageSize": 1000,
"sortKey": "date",
"sortDirection": "ascending",
"seriesId": series_id,
"eventType": 3 # Downloaded/Imported events only
},
timeout=self.timeout, retries=self.retries
)
return res.get("records", [])
# =================
# Radarr client
# =================
@dataclasses.dataclass
class RadarrClient:
url: str
api_key: str
timeout: int = RADARR_TIMEOUT
retries: int = RADARR_RETRIES
def _h(self) -> Dict[str, str]:
return {"X-Api-Key": self.api_key}
def ping(self) -> bool:
try:
_ = _req_json(
"GET", f"{self.url}/api/v3/system/status",
headers=self._h(), timeout=self.timeout, retries=self.retries
)
return True
except Exception as e:
log(f"WARN: Radarr ping failed: {e}")
return False
def movies_index(self) -> List[dict]:
return _req_json(
"GET", f"{self.url}/api/v3/movie",
headers=self._h(), timeout=self.timeout, retries=self.retries
)
def history_for(self, movie_id: int) -> List[dict]:
res = _req_json(
"GET", f"{self.url}/api/v3/history",
headers=self._h(), params={
"page": 1,
"pageSize": 1000,
"sortKey": "date",
"sortDirection": "ascending",
"movieId": movie_id,
"eventType": 3 # Downloaded/Imported events only
},
timeout=self.timeout, retries=self.retries
)
return res.get("records", [])
# =================
# TMDB client
# =================
@dataclasses.dataclass
class TMDBClient:
api_key: str
country: str = DEFAULT_TMDB_PRIMARY_COUNTRY
base_url: str = "https://api.themoviedb.org/3"
timeout: int = TMDB_TIMEOUT
retries: int = TMDB_RETRIES
def _p(self, **kw) -> Dict[str, object]:
p = {"api_key": self.api_key}
p.update(kw)
return p
def find_tv_by_imdb(self, imdb_id: str) -> Optional[int]:
js = _req_json(
"GET", f"{self.base_url}/find/{imdb_id}",
params=self._p(external_source="imdb_id"),
timeout=self.timeout, retries=self.retries
)
tv = js.get("tv_results") or []
if tv:
return tv[0].get("id")
return None
def find_movie_by_imdb(self, imdb_id: str) -> Optional[int]:
js = _req_json(
"GET", f"{self.base_url}/find/{imdb_id}",
params=self._p(external_source="imdb_id"),
timeout=self.timeout, retries=self.retries
)
movies = js.get("movie_results") or []
if movies:
return movies[0].get("id")
return None
def search_tv(self, name: str, first_air_date_year: Optional[int]) -> Optional[int]:
js = _req_json(
"GET", f"{self.base_url}/search/tv",
params=self._p(query=name, first_air_date_year=first_air_date_year or ""),
timeout=self.timeout, retries=self.retries
)
res = js.get("results") or []
if res:
return res[0].get("id")
return None
def search_movie(self, name: str, year: Optional[int]) -> Optional[int]:
js = _req_json(
"GET", f"{self.base_url}/search/movie",
params=self._p(query=name, year=year or "", primary_release_year=year or ""),
timeout=self.timeout, retries=self.retries
)
res = js.get("results") or []
if res:
return res[0].get("id")
return None
def episode_air_date(self, tv_id: int, season: int, episode: int) -> Optional[str]:
try:
js = _req_json(
"GET", f"{self.base_url}/tv/{tv_id}/season/{season}/episode/{episode}",
params=self._p(),
timeout=self.timeout, retries=self.retries
)
return js.get("air_date")
except Exception:
return None
def movie_release_date(self, movie_id: int) -> Optional[str]:
try:
js = _req_json(
"GET", f"{self.base_url}/movie/{movie_id}",
params=self._p(),
timeout=self.timeout, retries=self.retries
)
return js.get("release_date")
except Exception:
return None
# =================
# File parsing
# =================
@dataclasses.dataclass
class EpisodeKey:
series_title: str
year: Optional[int]
imdb_id: Optional[str]
tmdb_id: Optional[str]
season: int
episode: int
@dataclasses.dataclass
class MovieKey:
title: str
year: Optional[int]
imdb_id: Optional[str]
tmdb_id: Optional[str]
def parse_episode_from_path(path: Path) -> Optional[EpisodeKey]:
"""Parse episode info from file path."""
m = SE_EP_RX.search(path.name)
if not m:
return None
season = int(m.group(1))
episode = int(m.group(2))
series_dir = path.parent
if series_dir.name.lower().startswith("season "):
series_dir = series_dir.parent
series_title = series_dir.name
imdb_id = None
tmdb_id = None
year = None
m2 = IMDB_RX.search(series_title)
if m2:
imdb_id = m2.group(1)
m3 = TMDB_RX.search(series_title)
if m3:
tmdb_id = m3.group(1)
m4 = YEAR_RX.search(series_title)
if m4:
try:
year = int(m4.group(1))
except ValueError:
year = None
clean_title = IMDB_RX.sub("", series_title)
clean_title = TMDB_RX.sub("", clean_title)
clean_title = YEAR_RX.sub("", clean_title)
clean_title = clean_title.strip()
if not clean_title:
clean_title = series_title
return EpisodeKey(clean_title, year, imdb_id, tmdb_id, season, episode)
def parse_movie_from_path(path: Path) -> Optional[MovieKey]:
"""Parse movie info from file path."""
movie_dir = path.parent
movie_title = movie_dir.name
imdb_id = None
tmdb_id = None
year = None
m1 = IMDB_RX.search(movie_title)
if m1:
imdb_id = m1.group(1)
m2 = TMDB_RX.search(movie_title)
if m2:
tmdb_id = m2.group(1)
m3 = YEAR_RX.search(movie_title)
if m3:
try:
year = int(m3.group(1))
except ValueError:
year = None
clean_title = IMDB_RX.sub("", movie_title)
clean_title = TMDB_RX.sub("", clean_title)
clean_title = YEAR_RX.sub("", clean_title)
clean_title = clean_title.strip()
if not clean_title:
clean_title = movie_title
return MovieKey(clean_title, year, imdb_id, tmdb_id)
# =================
# Date choosing
# =================
def parse_date_yyyy_mm_dd(s: str) -> Optional[int]:
try:
y, m, d = map(int, s.split("-"))
return int(dt.datetime(y, m, d, 0, 0, 0, tzinfo=dt.timezone.utc).timestamp())
except Exception:
return None
def pick_from_sonarr_history(sc: SonarrClient, series_id: int) -> Optional[Tuple[int, str, Dict[str, object]]]:
"""Return (unix_ts, src, extra) or None."""
try:
hist = sc.history_for(series_id)
except Exception as e:
log(f"WARN: Sonarr history timeout/err series={series_id}: {e}")
return None
best_ts = None
best = None
for rec in hist:
event_type = rec.get("eventType")
if event_type not in [1, 3]: # 1=Grabbed, 3=Downloaded
continue
date_s = rec.get("date")
if not date_s:
continue
try:
t = dt.datetime.fromisoformat(date_s.replace("Z", "+00:00"))
ts = int(t.timestamp())
except Exception:
continue
if best_ts is None or ts < best_ts:
best_ts = ts
best = rec
if best_ts is not None:
return best_ts, "sonarr_history", {"history_id": best.get("id"), "event_type": best.get("eventType")}
return None
def pick_from_sonarr_episode(sc: SonarrClient, series_id: int, season: int, episode: int) -> Optional[Tuple[int, str, Dict[str, object]]]:
try:
eps = sc.episodes_for(series_id)
except Exception as e:
log(f"WARN: Sonarr episodes timeout/err series={series_id}: {e}")
return None
for ep in eps:
if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode:
air = ep.get("airDate") or ep.get("airDateUtc")
if not air:
return None
if "T" in air:
try:
t = dt.datetime.fromisoformat(air.replace("Z", "+00:00"))
return int(t.timestamp()), "sonarr_airdate", {"episodeId": ep.get("id")}
except Exception:
return None
ts = parse_date_yyyy_mm_dd(air)
if ts is not None:
return ts, "sonarr_airdate", {"episodeId": ep.get("id")}
return None
return None
# Similar functions for Radarr and TMDB...
def pick_from_tmdb_tv(tmdb: TMDBClient, epk: EpisodeKey) -> Optional[Tuple[int, str, Dict[str, object]]]:
if not tmdb.api_key:
return None
tv_id: Optional[int] = None
if epk.tmdb_id:
try:
tv_id = int(epk.tmdb_id)
except (ValueError, TypeError):
tv_id = None
if tv_id is None and epk.imdb_id:
try:
tv_id = tmdb.find_tv_by_imdb(epk.imdb_id)
except Exception as e:
log(f"WARN: TMDB find by IMDb failed {epk.imdb_id}: {e}")
if tv_id is None:
try:
tv_id = tmdb.search_tv(epk.series_title, epk.year)
except Exception as e:
log(f"WARN: TMDB search failed title={epk.series_title}: {e}")
if tv_id is None:
return None
air = tmdb.episode_air_date(tv_id, epk.season, epk.episode)
if not air:
return None
ts = parse_date_yyyy_mm_dd(air)
if ts is None:
return None
return ts, "tmdb_airdate", {"tv_id": tv_id, "country": tmdb.country}
# =================
# NFO Management
# =================
def create_simple_nfo(season: int, episode: int, air_date: Optional[str] = None, date_added: Optional[str] = None) -> str:
"""Create a simple NFO file content for SxxExx.nfo format."""
aired_tag = f"