Files
unmanic/media_date_cache.BEST
T
2025-08-24 12:02:34 -04:00

1120 lines
36 KiB
Python

#!/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" <aired>{air_date}</aired>\n" if air_date else ""
dateadded_tag = f" <dateadded>{date_added}</dateadded>\n" if date_added else ""
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<episodedetails>
{aired_tag}{dateadded_tag}</episodedetails>"""
def create_movie_nfo(title: str, year: Optional[int] = None, release_date: Optional[str] = None, date_added: Optional[str] = None) -> str:
"""Create a simple NFO file content for movie.nfo format."""
premiered_tag = f" <premiered>{release_date}</premiered>\n" if release_date else ""
dateadded_tag = f" <dateadded>{date_added}</dateadded>\n" if date_added else ""
year_tag = f" <year>{year}</year>\n" if year else ""
title_tag = f" <title>{title}</title>\n" if title else ""
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<movie>
{title_tag}{year_tag}{premiered_tag}{dateadded_tag}</movie>"""
def manage_nfo_files(
media_dir: Path,
kind: str,
season: int = 0,
episode: int = 0,
air_date: Optional[str] = None,
date_added: Optional[str] = None,
title: Optional[str] = None,
year: Optional[int] = None,
release_date: Optional[str] = None,
dry_run: bool = False
) -> Tuple[int, int]:
"""Manage NFO files. Returns (created_count, removed_count)."""
created = 0
removed = 0
if kind == "tv":
simple_nfo_name = f"S{season:02d}E{episode:02d}.nfo"
simple_nfo_path = media_dir / simple_nfo_name
nfo_content = create_simple_nfo(season, episode, air_date, date_added)
if not dry_run:
try:
should_write = True
if simple_nfo_path.exists():
existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore')
should_write = existing_content.strip() != nfo_content.strip()
if should_write:
simple_nfo_path.write_text(nfo_content, encoding='utf-8')
created = 1
except Exception as e:
log(f"WARN: Failed to create {simple_nfo_path}: {e}")
else:
# For dry run, check if we would create/update
if not simple_nfo_path.exists():
created = 1
else:
try:
existing_content = simple_nfo_path.read_text(encoding='utf-8', errors='ignore')
if existing_content.strip() != nfo_content.strip():
created = 1
except Exception:
created = 1
# Remove complex NFO files (but keep simple ones and season.nfo)
for nfo_file in media_dir.glob("*.nfo"):
nfo_name = nfo_file.name.lower()
# Keep these files
if NFO_SIMPLE_PATTERN.match(nfo_file.name):
continue # Keep S01E01.nfo format
if nfo_name == "season.nfo":
continue # Keep season.nfo
if nfo_name == "tvshow.nfo":
continue # Keep tvshow.nfo
# This is a complex NFO file - remove it
if not dry_run:
try:
nfo_file.unlink()
log(f"Removed complex NFO: {nfo_file.name}")
removed += 1
except Exception as e:
log(f"WARN: Failed to remove {nfo_file}: {e}")
else:
log(f"Would remove complex NFO: {nfo_file.name}")
removed += 1
return created, removed
# =================
# Sidecar files
# =================
def find_sidecar_files(media_file: Path) -> List[Path]:
"""Find sidecar files (subtitles, etc.) that match the media file."""
sidecars = []
base_name = media_file.stem
parent_dir = media_file.parent
for ext in SIDECAR_EXTS:
exact_match = parent_dir / f"{base_name}{ext}"
if exact_match.exists():
sidecars.append(exact_match)
for lang_file in parent_dir.glob(f"{base_name}.*{ext}"):
if lang_file not in sidecars:
sidecars.append(lang_file)
return sidecars
def apply_timestamp_to_file(file_path: Path, timestamp: int, dry_run: bool = False) -> bool:
"""Apply timestamp to a file. Returns True if successful."""
if dry_run:
return True
try:
os.utime(str(file_path), (timestamp, timestamp))
return True
except Exception as e:
log(f"WARN: Failed to set timestamp on {file_path}: {e}")
return False
# =================
# Scanning
# =================
def iter_media_files(roots: List[str]) -> Iterable[Path]:
for root in roots:
r = Path(root)
if not r.exists():
log(f"WARN: Root path does not exist: {root}")
continue
if r.is_file():
if r.suffix.lower() in VIDEO_EXTS:
yield r
continue
for p in r.rglob("*"):
if p.is_file() and p.suffix.lower() in VIDEO_EXTS:
yield p
def choose_series_id_for_path(series_index: List[dict], path: Path) -> Optional[int]:
"""Try to map a path to a Sonarr seriesId by matching the series' 'path' as a prefix."""
series_dir = path.parent
if series_dir.name.lower().startswith("season "):
series_dir = series_dir.parent
sdir = str(series_dir)
best_len = -1
best_sid = None
for s in series_index:
spath = s.get("path", "")
if spath and sdir.startswith(spath):
L = len(spath)
if L > best_len:
best_len = L
best_sid = s.get("id")
return best_sid
# =================
# Apply command
# =================
def apply_command(args: argparse.Namespace) -> None:
"""Main apply command function."""
log("=== Media Date Sync Starting ===")
# Load environment first
load_env_file(args.env)
# NOW get the updated environment variables
sonarr_url = os.getenv("SONARR_URL", "http://sonarr:8989")
sonarr_key = os.getenv("SONARR_API_KEY", "")
radarr_url = os.getenv("RADARR_URL", "http://radarr:7878")
radarr_key = os.getenv("RADARR_API_KEY", "")
tmdb_key = os.getenv("TMDB_API_KEY", "")
tmdb_country = os.getenv("TMDB_PRIMARY_COUNTRY", "US")
# Debug: Show what we loaded
log(f"SONARR_URL: {sonarr_url}")
log(f"SONARR_API_KEY: {'SET' if sonarr_key else 'MISSING'}")
log(f"TMDB_API_KEY: {'SET' if tmdb_key else 'MISSING'}")
# Resolve clients with updated environment
sc = None
if sonarr_key and sonarr_url:
sc = SonarrClient(sonarr_url, sonarr_key)
if not sc.ping():
log("WARN: Sonarr not reachable")
sc = None
else:
log("Sonarr connection successful")
else:
log("WARN: Sonarr not configured")
tmdb = TMDBClient(tmdb_key, tmdb_country)
if not tmdb_key:
log("WARN: TMDB not configured - limited functionality")
# Initialize database
cx = sqlite3.connect(args.db)
db_init(cx)
log(f"Database initialized: {args.db}")
# Load Sonarr series index
series_index: List[dict] = []
if args.kind == "tv" and sc:
try:
log("Loading Sonarr series index...")
series_index = sc.series_index()
log(f"Loaded {len(series_index)} series from Sonarr")
except Exception as e:
log(f"WARN: Failed to load Sonarr series: {e}")
# Determine root paths
roots = args.root or []
if not roots:
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 []
if args.kind == "tv" and tv_roots:
roots = [r for r in tv_roots if r.strip()]
log(f"Using TV_ROOTS from environment: {', '.join(roots)}")
elif args.kind == "movie" and movie_roots:
roots = [r for r in movie_roots if r.strip()]
log(f"Using MOVIE_ROOTS from environment: {', '.join(roots)}")
if not roots:
log("ERROR: No root paths specified. Use --root or set TV_ROOTS/MOVIE_ROOTS in .env")
return
log(f"Scanning for media files in: {', '.join(roots)}")
files = list(iter_media_files(roots))
log(f"Found {len(files)} media file(s)")
if not files:
log("No media files found. Check your root paths and permissions.")
return
# Processing counters
checked = 0
updated = 0
misses = 0
cached = 0
nfo_created = 0
nfo_removed = 0
sidecars_updated = 0
log(f"Starting processing: refresh={args.refresh}, dry_run={args.dry_run}, manage_nfo={args.manage_nfo}")
for fp in files:
checked += 1
path = str(fp)
if checked <= 5: # Show detailed info for first 5 files
log(f"Processing file {checked}: {fp.name}")
# Check cache first (unless refresh requested)
if not args.refresh:
cached_result = db_get_cached(cx, path)
if cached_result:
cached_ts, cached_src = cached_result
if checked <= 5:
log(f" Using cached result: {cached_src}")
# Apply cached timestamp to media file
if apply_timestamp_to_file(fp, cached_ts, args.dry_run):
cached += 1
# Also update sidecar files
sidecar_files = find_sidecar_files(fp)
for sidecar in sidecar_files:
if apply_timestamp_to_file(sidecar, cached_ts, args.dry_run):
sidecars_updated += 1
continue
# Parse media file based on type
ts_src_extra: Optional[Tuple[int, str, Dict[str, object]]] = None
air_date_str = None
date_added_str = None
if args.kind == "tv":
epk = parse_episode_from_path(fp)
if not epk:
if checked <= 10: # Only log first 10 parse failures to avoid spam
log(f"WARN: Could not parse episode info from: {fp.name}")
misses += 1
continue
if checked <= 5:
log(f" Parsed: {epk.series_title} S{epk.season:02d}E{epk.episode:02d}")
# Try Sonarr first
series_id = choose_series_id_for_path(series_index, fp) if series_index else None
if checked <= 5:
log(f" Sonarr series ID: {series_id}")
if sc and series_id:
if checked <= 5:
log(f" Checking Sonarr history...")
# Try history first (import date)
history_result = pick_from_sonarr_history(sc, series_id)
if history_result:
ts_src_extra = history_result
if checked <= 5:
log(f" Got history: {history_result[1]}")
try:
date_added_dt = dt.datetime.fromtimestamp(history_result[0], tz=dt.timezone.utc)
date_added_str = date_added_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
if checked <= 5:
log(f" Checking Sonarr episode data...")
# Get air date regardless
episode_result = pick_from_sonarr_episode(sc, series_id, epk.season, epk.episode)
if episode_result:
if not ts_src_extra: # Use air date if no history
ts_src_extra = episode_result
if not date_added_str:
try:
date_added_dt = dt.datetime.fromtimestamp(episode_result[0], tz=dt.timezone.utc)
date_added_str = date_added_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
# Extract air date for NFO
try:
air_date_ts = episode_result[0]
air_date_dt = dt.datetime.fromtimestamp(air_date_ts, tz=dt.timezone.utc)
air_date_str = air_date_dt.strftime("%Y-%m-%d")
except Exception:
pass
# TMDB fallback
if not ts_src_extra and tmdb_key:
if checked <= 5:
log(f" Trying TMDB fallback...")
tmdb_result = pick_from_tmdb_tv(tmdb, epk)
if tmdb_result:
ts_src_extra = tmdb_result
if checked <= 5:
log(f" Got TMDB result: {tmdb_result[1]}")
try:
air_date_ts = tmdb_result[0]
air_date_dt = dt.datetime.fromtimestamp(air_date_ts, tz=dt.timezone.utc)
air_date_str = air_date_dt.strftime("%Y-%m-%d")
if not date_added_str:
date_added_str = air_date_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
else: # movie support would go here
log("Movie support not implemented yet")
misses += 1
continue
# Last resort: keep existing mtime if allowed
if not ts_src_extra and args.live_on_miss:
try:
mtime = int(fp.stat().st_mtime)
ts_src_extra = (mtime, "existing_mtime", {})
if checked <= 5:
log(f" Using existing mtime as fallback")
except Exception:
ts_src_extra = None
if not ts_src_extra:
if checked <= 10: # Only log first 10 misses to avoid spam
log(f"WARN: No date found for: {fp.name}")
misses += 1
continue
ts, src, extra = ts_src_extra
if checked <= 5:
log(f" Final source: {src}, timestamp: {dt.datetime.fromtimestamp(ts)}")
# Apply timestamp to media file
if apply_timestamp_to_file(fp, ts, args.dry_run):
updated += 1
# Apply timestamp to sidecar files
sidecar_files = find_sidecar_files(fp)
if sidecar_files and checked <= 5:
log(f" Found {len(sidecar_files)} sidecar files")
for sidecar in sidecar_files:
if apply_timestamp_to_file(sidecar, ts, args.dry_run):
sidecars_updated += 1
# Handle NFO files for TV shows
if args.kind == "tv" and args.manage_nfo:
created, removed = manage_nfo_files(
fp.parent, "tv", epk.season, epk.episode,
air_date_str, date_added_str, dry_run=args.dry_run
)
if (created or removed) and checked <= 5:
log(f" NFO: created={created}, removed={removed}")
nfo_created += created
nfo_removed += removed
# Update database
if not args.dry_run:
try:
if args.kind == "tv":
extra_data = {
**(extra or {}),
"series_title": epk.series_title,
"season": epk.season,
"episode": epk.episode,
}
else:
extra_data = extra or {}
db_upsert(cx, path, args.kind, ts, src, extra_data)
except sqlite3.OperationalError as e:
log(f"WARN: DB write failed: {e}")
# Progress updates
if checked % 500 == 0:
log(f"Progress: {checked}/{len(files)} (updated: {updated}, cached: {cached}, misses: {misses})")
# Final results
completion_msg = f"Media date sync completed:\n" \
f"Kind: {args.kind}\n" \
f"Checked: {checked}\n" \
f"Updated: {updated}\n" \
f"Cached: {cached}\n" \
f"Misses: {misses}\n" \
f"Sidecars updated: {sidecars_updated}\n"
if args.manage_nfo:
completion_msg += f"NFO created: {nfo_created}\n" \
f"NFO removed: {nfo_removed}\n"
completion_msg += f"Dry run: {args.dry_run}"
log(f"=== Complete ===")
log(completion_msg.replace('\n', ', '))
# Send notification
send_gotify_notification("Media Date Sync Complete", completion_msg)
# =================
# CLI
# =================
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Apply canonical mtimes to media files using Sonarr/Radarr/TMDB fallbacks.")
sub = p.add_subparsers(dest="cmd", required=True)
# apply
ap = sub.add_parser("apply", help="Scan, resolve dates, apply mtimes, and record to DB.")
ap.add_argument("--env", help="Optional .env file with API keys and URLs")
ap.add_argument("--kind", choices=["tv", "movie"], required=True, help="Media kind")
ap.add_argument("--db", required=True, help="SQLite DB file path")
ap.add_argument("--root", action="append", help="Root folder(s) to scan; repeatable (or use TV_ROOTS/MOVIE_ROOTS in .env)")
ap.add_argument("--refresh", action="store_true", help="Ignore cached results and re-fetch all dates")
ap.add_argument("--dry-run", action="store_true", help="Do not write mtime/DB; just log what would happen")
ap.add_argument("--live-on-miss", action="store_true", help="If no date found, keep file's current mtime as chosen date")
ap.add_argument("--manage-nfo", action="store_true", help="Create simple NFO files with air/release dates and date added (import dates). Remove complex NFOs.")
return p
def main() -> None:
try:
parser = build_arg_parser()
args = parser.parse_args()
if args.cmd == "apply":
apply_command(args)
else:
parser.error("unknown command")
except KeyboardInterrupt:
log("Interrupted.")
sys.exit(1)
except Exception as e:
log(f"FATAL ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()