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

943 lines
34 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
media_date_cache.py
Builds and applies "best possible" dates to media files, caching choices in SQLite.
Sources, in priority order:
TV (kind=tv):
1) Sonarr first-import date (history and/or episodeFile.dateAdded)
2) Sonarr episode airDateUtc
3) TMDB episode air_date (via folder IMDb ID)
4) (optional) file mtime fallback if --fallback file_mtime
Movies (kind=movie):
1) Radarr first-import (history and/or movieFile.dateAdded)
2) Radarr release dates (digital -> physical -> theatrical)
3) TMDB release dates (digital -> physical -> theatrical) via folder IMDb ID
4) (optional) file mtime fallback if --fallback file_mtime
Environment (.env supported):
SONARR_URL, SONARR_API_KEY
RADARR_URL, RADARR_API_KEY
TMDB_API_KEY
TMDB_PRIMARY_COUNTRY=US
GOTIFY_URL, GOTIFY_TOKEN, GOTIFY_PRIORITY=5
GOTIFY_TICK_EVERY=500
TV_ROOTS=/mnt/unionfs/Media/TV:/mnt/unionfs/Media/TV/tv:/mnt/unionfs/Media/TV/tv6
MOVIE_ROOTS=/mnt/unionfs/Media/Movies:/mnt/unionfs/Media/Movies/movies:/mnt/unionfs/Media/Movies/movies6
Usage examples:
python media_date_cache.py apply --kind tv --db /opt/scripts/unmanic/.cache/media_dates.db --root /mnt/unionfs/Media/TV --live-on-miss
python media_date_cache.py apply --kind movie --db /opt/scripts/unmanic/.cache/media_dates.db --root /mnt/unionfs/Media/Movies --live-on-miss
Requires:
pip install requests python-dotenv
"""
import argparse
import datetime as dt
import json
import os
import re
import sqlite3
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
try:
from dotenv import load_dotenv
except Exception:
load_dotenv = None
import requests
VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"}
# ---------- Utilities ----------
def log(msg: str):
now = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{now}] {msg}", flush=True)
def load_env(env_file: Optional[str] = None) -> Dict[str, str]:
if env_file and os.path.isfile(env_file):
if load_dotenv:
load_dotenv(env_file)
else:
# Auto-load .env from cwd if present
local = Path(".env")
if local.exists() and load_dotenv:
load_dotenv(local)
return dict(os.environ)
def env_get(env: Dict[str, str], key: str, default: Optional[str] = None) -> Optional[str]:
val = env.get(key, default)
return val
def env_get_int(env: Dict[str, str], key: str, default: int) -> int:
raw = str(env.get(key, str(default)) or str(default))
# tolerate trailing comments like: "5 # optional"
raw = raw.strip().split()[0]
try:
return int(raw)
except Exception:
return default
def parse_utc(ts: str) -> Optional[int]:
if not ts:
return None
# Try a few common formats
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z",
"%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
try:
if fmt.endswith("Z"):
dt_obj = dt.datetime.strptime(ts, fmt).replace(tzinfo=dt.timezone.utc)
elif fmt == "%Y-%m-%d":
# interpret bare dates as midnight UTC
dt_obj = dt.datetime.strptime(ts, "%Y-%m-%d").replace(tzinfo=dt.timezone.utc)
else:
dt_obj = dt.datetime.strptime(ts, fmt)
return int(dt_obj.timestamp())
except Exception:
continue
return None
def dt_to_epoch_utc(d: dt.datetime) -> int:
if d.tzinfo is None:
d = d.replace(tzinfo=dt.timezone.utc)
return int(d.timestamp())
def safe_join_paths(paths: List[str]) -> List[str]:
out = []
for p in paths:
if p and os.path.isdir(p):
out.append(p)
return out
IMDB_RX = re.compile(r"(?:^|\W)(?:imdb-)?(tt\d{6,9})(?:\W|$)", re.IGNORECASE)
SXXEYY_RX = re.compile(r"[Ss](\d{1,3})[ ._-]*[Ee](\d{1,3})")
def extract_imdb_from_path(path: str) -> Optional[str]:
# Search each segment (show or movie folder often has it)
for part in Path(path).parts[::-1]:
m = IMDB_RX.search(part)
if m:
return m.group(1).lower()
return None
def parse_sxxeyy(path: str) -> Optional[Tuple[int, int]]:
m = SXXEYY_RX.search(path)
if not m:
return None
try:
return int(m.group(1)), int(m.group(2))
except Exception:
return None
# ---------- Gotify ----------
def gotify_send(env: Dict[str, str], title: str, message: str, priority: Optional[int] = None):
url = env_get(env, "GOTIFY_URL")
token = env_get(env, "GOTIFY_TOKEN")
if not url or not token:
return
prio = priority if priority is not None else env_get_int(env, "GOTIFY_PRIORITY", 5)
try:
r = requests.post(
f"{url.rstrip('/')}/message",
headers={"X-Gotify-Key": token},
json={"title": title, "message": message, "priority": prio},
timeout=10,
)
r.raise_for_status()
except Exception as e:
log(f"GOTIFY WARN: {e}")
# ---------- SQLite ----------
DDL_MEDIA_DATES = """
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_json TEXT,
first_seen INTEGER NOT NULL,
last_update INTEGER NOT NULL
);
"""
IDX_MEDIA_DATES_KIND = "CREATE INDEX IF NOT EXISTS idx_media_dates_kind ON media_dates(kind);"
def ensure_db(db_path: str):
os.makedirs(os.path.dirname(db_path), exist_ok=True)
with sqlite3.connect(db_path) as cx:
cx.execute(DDL_MEDIA_DATES)
cx.execute(IDX_MEDIA_DATES_KIND)
cx.commit()
def db_get_row(cx: sqlite3.Connection, path: str) -> Optional[Tuple]:
cur = cx.execute("SELECT path, kind, chosen_ts, chosen_src, extra_json, first_seen, last_update FROM media_dates WHERE path=?", (path,))
return cur.fetchone()
def db_upsert(cx: sqlite3.Connection, path: str, kind: str, chosen_ts: int, chosen_src: str, extra: dict):
now = int(time.time())
prev = db_get_row(cx, path)
if prev:
first_seen = prev[5]
cx.execute(
"UPDATE media_dates SET chosen_ts=?, chosen_src=?, extra_json=?, last_update=? WHERE path=?",
(chosen_ts, chosen_src, json.dumps(extra, ensure_ascii=False), now, path),
)
else:
first_seen = now
cx.execute(
"INSERT INTO media_dates (path, kind, chosen_ts, chosen_src, extra_json, first_seen, last_update) VALUES (?,?,?,?,?,?,?)",
(path, kind, chosen_ts, chosen_src, json.dumps(extra, ensure_ascii=False), first_seen, now),
)
# ---------- API clients (Sonarr, Radarr, TMDB) ----------
def _req_json(method: str, url: str, headers=None, params=None, timeout=30):
r = requests.request(method, url, headers=headers or {}, params=params or {}, timeout=timeout)
r.raise_for_status()
return r.json()
class SonarrClient:
def __init__(self, base: Optional[str], key: Optional[str]):
self.base = base.rstrip("/") if base else None
self.key = key
self._series = None # list
self._episodes = {} # seriesId -> list
self._history = {} # seriesId -> list
self._epfiles = {} # seriesId -> list
def ok(self) -> bool:
return bool(self.base and self.key)
def _hdr(self):
return {"X-Api-Key": self.key}
def series(self) -> List[dict]:
if self._series is not None:
return self._series
if not self.ok():
self._series = []
return self._series
log("Sonarr: loading series index…")
js = _req_json("GET", f"{self.base}/api/v3/series", headers=self._hdr())
if isinstance(js, list):
self._series = js
else:
# defensive
self._series = list(js) if js else []
log(f"Sonarr: indexed {len(self._series)} series")
return self._series
def episodes_for(self, series_id: int) -> List[dict]:
if series_id in self._episodes:
return self._episodes[series_id]
if not self.ok():
self._episodes[series_id] = []
return self._episodes[series_id]
js = _req_json("GET", f"{self.base}/api/v3/episode", headers=self._hdr(), params={"seriesId": series_id})
if isinstance(js, list):
out = js
elif isinstance(js, dict) and "records" in js:
out = js["records"]
else:
out = []
self._episodes[series_id] = out
return out
def episode_files_for(self, series_id: int) -> List[dict]:
if series_id in self._epfiles:
return self._epfiles[series_id]
if not self.ok():
self._epfiles[series_id] = []
return self._epfiles[series_id]
js = _req_json("GET", f"{self.base}/api/v3/episodefile", headers=self._hdr(), params={"seriesId": series_id})
self._epfiles[series_id] = js if isinstance(js, list) else []
return self._epfiles[series_id]
def history_for(self, series_id: int) -> List[dict]:
if series_id in self._history:
return self._history[series_id]
if not self.ok():
self._history[series_id] = []
return self._history[series_id]
# series history paginated
page = 1
page_size = 500
records: List[dict] = []
while True:
js = _req_json(
"GET",
f"{self.base}/api/v3/history/series",
headers=self._hdr(),
params={"seriesId": series_id, "includeEpisode": "true", "page": page, "pageSize": page_size},
)
chunk = []
if isinstance(js, dict):
chunk = js.get("records") or js.get("results") or []
total = js.get("totalRecords") or js.get("total") or 0
elif isinstance(js, list):
chunk = js
total = len(js)
records.extend(chunk)
if isinstance(js, dict) and js.get("page") is not None:
# If paginated, stop when we fetched all
if len(records) >= total:
break
page += 1
else:
# Not paginated
break
self._history[series_id] = records
return records
class RadarrClient:
def __init__(self, base: Optional[str], key: Optional[str]):
self.base = base.rstrip("/") if base else None
self.key = key
self._movies = None
self._history = None
self._files_by_movie = {}
def ok(self) -> bool:
return bool(self.base and self.key)
def _hdr(self):
return {"X-Api-Key": self.key}
def movies(self) -> List[dict]:
if self._movies is not None:
return self._movies
if not self.ok():
self._movies = []
return self._movies
js = _req_json("GET", f"{self.base}/api/v3/movie", headers=self._hdr())
self._movies = js if isinstance(js, list) else []
return self._movies
def history(self) -> List[dict]:
if self._history is not None:
return self._history
if not self.ok():
self._history = []
return self._history
page = 1
page_size = 500
records: List[dict] = []
while True:
js = _req_json("GET", f"{self.base}/api/v3/history", headers=self._hdr(), params={"page": page, "pageSize": page_size})
chunk = []
total = 0
if isinstance(js, dict):
chunk = js.get("records") or js.get("results") or []
total = js.get("totalRecords") or js.get("total") or 0
elif isinstance(js, list):
chunk = js
total = len(js)
records.extend(chunk)
if isinstance(js, dict) and js.get("page") is not None:
if len(records) >= total:
break
page += 1
else:
break
self._history = records
return records
def moviefile_for(self, movie_id: int) -> Optional[dict]:
if movie_id in self._files_by_movie:
return self._files_by_movie[movie_id]
if not self.ok():
self._files_by_movie[movie_id] = None
return None
js = _req_json("GET", f"{self.base}/api/v3/moviefile", headers=self._hdr(), params={"movieId": movie_id})
# could be list, pick first if any
mf = js[0] if isinstance(js, list) and js else None
self._files_by_movie[movie_id] = mf
return mf
class TMDBClient:
def __init__(self, api_key: Optional[str], primary_country: str = "US"):
self.key = api_key
self.primary_country = (primary_country or "US").upper()
self.base = "https://api.themoviedb.org/3"
self._tv_id_cache: Dict[str, int] = {} # imdb_id -> tmdb_tv_id
self._movie_id_cache: Dict[str, int] = {} # imdb_id -> tmdb_movie_id
self._season_cache: Dict[Tuple[int, int], dict] = {} # (tv_id, season)-> episodes mapping
def ok(self) -> bool:
return bool(self.key)
def _get(self, path: str, params: dict = None):
if not self.ok():
return None
params = dict(params or {})
params["api_key"] = self.key
return _req_json("GET", f"{self.base}{path}", params=params)
def tv_id_by_imdb(self, imdb_id: str) -> Optional[int]:
imdb_id = imdb_id.lower()
if imdb_id in self._tv_id_cache:
return self._tv_id_cache[imdb_id]
js = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
tid = None
if isinstance(js, dict):
tvres = js.get("tv_results") or []
if tvres:
tid = tvres[0].get("id")
self._tv_id_cache[imdb_id] = tid
return tid
def movie_id_by_imdb(self, imdb_id: str) -> Optional[int]:
imdb_id = imdb_id.lower()
if imdb_id in self._movie_id_cache:
return self._movie_id_cache[imdb_id]
js = self._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
mid = None
if isinstance(js, dict):
mres = js.get("movie_results") or []
if mres:
mid = mres[0].get("id")
self._movie_id_cache[imdb_id] = mid
return mid
def season_airdates(self, tv_id: int, season_number: int) -> Dict[int, str]:
key = (tv_id, season_number)
if key in self._season_cache:
return self._season_cache[key]
js = self._get(f"/tv/{tv_id}/season/{season_number}")
mapping: Dict[int, str] = {}
if isinstance(js, dict):
for ep in js.get("episodes") or []:
epno = ep.get("episode_number")
ad = ep.get("air_date")
if epno and ad:
mapping[int(epno)] = ad
self._season_cache[key] = mapping
return mapping
def movie_best_release(self, movie_id: int) -> Optional[Tuple[str, str]]:
"""
Returns (src_key, release_date_iso) where src_key in {'tmdb_digital','tmdb_physical','tmdb_theatrical'}
Preference order: digital(4) -> physical(5) -> theatrical(3), prioritizing primary_country, else any.
"""
js = self._get(f"/movie/{movie_id}/release_dates")
if not isinstance(js, dict):
return None
results = js.get("results") or []
def pick(for_country: Optional[str]) -> Optional[Tuple[str, str]]:
best = {}
for entry in results:
if for_country and (entry.get("iso_3166_1") != for_country):
continue
for rel in entry.get("release_dates") or []:
rtype = int(rel.get("type") or 0)
rdate = rel.get("release_date")
if not rdate:
continue
if rtype == 4: # digital
best.setdefault("tmdb_digital", []).append(rdate)
elif rtype == 5: # physical
best.setdefault("tmdb_physical", []).append(rdate)
elif rtype == 3: # theatrical
best.setdefault("tmdb_theatrical", []).append(rdate)
# choose earliest of preferred buckets
for key in ("tmdb_digital", "tmdb_physical", "tmdb_theatrical"):
if key in best:
return key, sorted(best[key])[0]
return None
# Try primary country first, then any
chosen = pick(self.primary_country) or pick(None)
return chosen
# ---------- Matching helpers ----------
def longest_prefix_match(target: str, prefix_map: Dict[str, dict]) -> Optional[dict]:
"""
prefix_map: series_path -> series_obj (Sonarr), or movie_path -> movie_obj (Radarr)
Returns the mapped object whose path is the longest prefix of target.
"""
candidates = []
t = target.lower()
for p, obj in prefix_map.items():
pl = p.lower()
if t.startswith(pl.rstrip("/") + "/") or t == pl:
candidates.append((len(pl), obj))
if not candidates:
return None
candidates.sort(key=lambda x: x[0], reverse=True)
return candidates[0][1]
def expand_roots_from_env(env: Dict[str, str], kind: str, cli_roots: List[str]) -> List[str]:
roots = []
if cli_roots:
roots.extend(cli_roots)
else:
env_key = "TV_ROOTS" if kind == "tv" else "MOVIE_ROOTS"
val = env_get(env, env_key)
if val:
roots.extend([p.strip() for p in val.split(":") if p.strip()])
# Add known child dirs automatically if they exist
expanded = []
for r in roots:
expanded.append(r)
# Only add children for top-level TV/Movie roots
if kind == "tv":
for child in ("tv", "tv6"):
c = os.path.join(r, child)
if os.path.isdir(c):
expanded.append(c)
else:
for child in ("movies", "movies6"):
c = os.path.join(r, child)
if os.path.isdir(c):
expanded.append(c)
# dedupe and only keep existing dirs
uniq = []
seen = set()
for p in expanded:
if os.path.isdir(p) and p not in seen:
uniq.append(p)
seen.add(p)
return uniq
def walk_media_files(roots: List[str]) -> List[str]:
files = []
for root in roots:
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
if Path(fn).suffix.lower() in VIDEO_EXTS:
files.append(os.path.join(dirpath, fn))
return files
# ---------- Choosing timestamps ----------
def sonarr_choose_for_file(sc: SonarrClient, file_path: str) -> Optional[Tuple[int, str, dict]]:
"""
If the file belongs to a Sonarr series (by path prefix), try to determine a timestamp:
- earliest import (history or episodeFile.dateAdded)
- else episode airDateUtc
Returns (ts, src, extra) or None
"""
if not sc.ok():
return None
series_index = {s.get("path") or "": s for s in sc.series() if (s.get("path") or "").strip()}
series = longest_prefix_match(file_path, series_index)
if not series:
return None
sid = series.get("id")
eps = sc.episodes_for(sid)
epfiles = sc.episode_files_for(sid)
hist = sc.history_for(sid)
# Build lookup maps
# Map (season,episode) -> episode id and airDateUtc
ep_map: Dict[Tuple[int, int], dict] = {}
for e in eps:
try:
k = (int(e.get("seasonNumber") or 0), int(e.get("episodeNumber") or 0))
ep_map[k] = e
except Exception:
continue
# episodeId -> earliest import date (from history)
import_map: Dict[int, int] = {}
for r in hist:
try:
ev = (r.get("eventType") or r.get("event") or "")
evl = str(ev).lower()
if "import" not in evl:
continue
edate = r.get("date")
ts = parse_utc(edate)
if ts is None:
continue
ep_obj = r.get("episode") or {}
eid = ep_obj.get("id")
if not eid:
continue
prev = import_map.get(eid)
if prev is None or ts < prev:
import_map[eid] = ts
except Exception:
continue
# episodeFile dateAdded (another hint)
# episodeFile doesn't directly map to episodeId always, but often the filename/relativePath implies SxxEyy.
# We'll only use dateAdded as a general earliest among files that share the same S/E when we can parse S/E.
# We'll handle per-file below.
# Parse S/E from the path
se = parse_sxxeyy(file_path)
if not se:
return None
s_num, e_num = se
ep = ep_map.get((s_num, e_num))
if not ep:
return None
eid = ep.get("id")
# Candidate 1: history first import for this episode
candidates: List[Tuple[int, str]] = []
if eid and eid in import_map:
candidates.append((import_map[eid], "sonarr_import"))
# Candidate 2: episodeFile.dateAdded for matching S/E if any
# Try to match on season/episode parsed from episodeFile.relativePath
earliest_epfile_ts = None
for ef in epfiles:
rel = ef.get("relativePath") or ""
se2 = parse_sxxeyy(rel)
if se2 == (s_num, e_num):
ts2 = parse_utc(ef.get("dateAdded"))
if ts2 is not None and (earliest_epfile_ts is None or ts2 < earliest_epfile_ts):
earliest_epfile_ts = ts2
if earliest_epfile_ts is not None:
candidates.append((earliest_epfile_ts, "episodefile_dateAdded"))
# Candidate 3: airDateUtc
air_ts = parse_utc(ep.get("airDateUtc") or ep.get("airDate"))
if air_ts is not None:
candidates.append((air_ts, "tv_airdate"))
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
chosen_ts, chosen_src = candidates[0]
extra = {
"seriesId": sid,
"seriesTitle": series.get("title"),
"season": s_num,
"episode": e_num,
"candidates": [{"ts": ts, "src": src} for ts, src in candidates],
}
return chosen_ts, chosen_src, extra
def radarr_choose_for_file(rc: RadarrClient, file_path: str) -> Optional[Tuple[int, str, dict]]:
"""
Determine movie timestamp using Radarr:
- earliest import from history/movieFile.dateAdded
- else Radarr release dates (digital->physical->theatrical)
"""
if not rc.ok():
return None
movies_index = {m.get("path") or "": m for m in rc.movies() if (m.get("path") or "").strip()}
movie = longest_prefix_match(file_path, movies_index)
if not movie:
return None
mid = movie.get("id")
candidates: List[Tuple[int, str]] = []
# History import events
for r in rc.history():
try:
ev = (r.get("eventType") or r.get("event") or "")
evl = str(ev).lower()
if "import" not in evl:
continue
mobj = r.get("movie") or {}
if mobj.get("id") != mid:
continue
ts = parse_utc(r.get("date"))
if ts is not None:
candidates.append((ts, "radarr_import"))
except Exception:
continue
# Movie file dateAdded
mf = rc.moviefile_for(mid)
mf_ts = parse_utc(mf.get("dateAdded")) if mf else None
if mf_ts is not None:
candidates.append((mf_ts, "moviefile_dateAdded"))
# Radarr release dates on the movie object itself
# Radarr's movie object has fields like 'digitalRelease', 'physicalRelease', 'inCinemas'
for key, src in (("digitalRelease", "movie_digital"),
("physicalRelease", "movie_physical"),
("inCinemas", "movie_cinema")):
ts = parse_utc(movie.get(key))
if ts is not None:
candidates.append((ts, src))
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
chosen_ts, chosen_src = candidates[0]
extra = {
"movieId": mid,
"movieTitle": movie.get("title"),
"candidates": [{"ts": ts, "src": src} for ts, src in candidates],
}
return chosen_ts, chosen_src, extra
def tmdb_tv_choose(file_path: str, tmdb: TMDBClient) -> Optional[Tuple[int, str, dict]]:
"""
For a TV episode when Sonarr lacks it:
- Extract IMDb ID from path
- Map to TMDB tv_id
- Parse season/episode from filename
- Fetch season's episodes and get air_date for the episode
"""
if not tmdb.ok():
return None
imdb_id = extract_imdb_from_path(file_path)
if not imdb_id:
return None
se = parse_sxxeyy(file_path)
if not se:
return None
s_num, e_num = se
tv_id = tmdb.tv_id_by_imdb(imdb_id)
if not tv_id:
return None
airdates = tmdb.season_airdates(tv_id, s_num)
iso = airdates.get(e_num)
if not iso:
return None
ts = parse_utc(iso)
if ts is None:
return None
extra = {
"imdb": imdb_id,
"tmdb_tv_id": tv_id,
"season": s_num,
"episode": e_num,
"tmdb_air_date": iso,
}
return ts, "tmdb_airdate", extra
def tmdb_movie_choose(file_path: str, tmdb: TMDBClient) -> Optional[Tuple[int, str, dict]]:
if not tmdb.ok():
return None
imdb_id = extract_imdb_from_path(file_path)
if not imdb_id:
return None
movie_id = tmdb.movie_id_by_imdb(imdb_id)
if not movie_id:
return None
picked = tmdb.movie_best_release(movie_id)
if not picked:
return None
src, iso = picked
ts = parse_utc(iso)
if ts is None:
return None
extra = {"imdb": imdb_id, "tmdb_movie_id": movie_id, "tmdb_release": {"src": src, "date": iso}}
return ts, src, extra
# ---------- Apply ----------
def apply_mtime(path: str, ts: int, dry_run: bool = False) -> bool:
try:
if dry_run:
return True
os.utime(path, (ts, ts))
return True
except Exception as e:
log(f"ERROR utime failed for {path}: {e}")
return False
def apply_command(args):
env = load_env(args.env)
ensure_db(args.db)
# Clients
sc = SonarrClient(env_get(env, "SONARR_URL"), env_get(env, "SONARR_API_KEY"))
rc = RadarrClient(env_get(env, "RADARR_URL"), env_get(env, "RADARR_API_KEY"))
tmdb = TMDBClient(env_get(env, "TMDB_API_KEY"), env_get(env, "TMDB_PRIMARY_COUNTRY") or "US")
tick_every = env_get_int(env, "GOTIFY_TICK_EVERY", 500)
# Roots
roots = expand_roots_from_env(env, args.kind, args.root or [])
if not roots:
log(f"ERROR: No roots found for kind={args.kind}. Use --root or set TV_ROOTS/MOVIE_ROOTS.")
sys.exit(2)
files = walk_media_files(roots)
log(f"Found {len(files)} media file(s) under {', '.join(roots)}")
# If asked, prewarm Sonarr/Radarr series indexes (episodes/history lazy anyway)
if args.kind == "tv" and args.live_on_miss:
log("Initializing Sonarr (series index only)…")
sc.series() # index
if args.kind == "movie" and args.live_on_miss:
log("Initializing Radarr (movies index only)…")
rc.movies()
gotify_send(env, "Apply mtimes: start", f"kind={args.kind}, files={len(files)}")
updated = 0
checked = 0
misses = 0
with sqlite3.connect(args.db) as cx:
for path in files:
checked += 1
# Existing cached?
row = db_get_row(cx, path)
if row and not args.refresh:
# Already have it — apply to file (unless skipping)
ts, src = row[2], row[3]
ok = apply_mtime(path, ts, dry_run=args.dry_run)
if ok:
updated += 1
if checked % tick_every == 0:
gotify_send(env, "Apply mtimes: progress", f"{checked}/{len(files)} (cached)")
continue
# Need to compute live
chosen: Optional[Tuple[int, str, dict]] = None
if args.kind == "tv":
if args.live_on_miss and sc.ok():
chosen = sonarr_choose_for_file(sc, path)
if not chosen:
# TMDB fallback
chosen = tmdb_tv_choose(path, tmdb)
else:
# movies
if args.live_on_miss and rc.ok():
chosen = radarr_choose_for_file(rc, path)
if not chosen:
chosen = tmdb_movie_choose(path, tmdb)
if not chosen and args.fallback == "file_mtime":
try:
st = os.stat(path)
ts = int(st.st_mtime)
chosen = (ts, "file_mtime", {})
except Exception:
chosen = None
if not chosen:
log(f"WARN: no import/release date for {path}")
misses += 1
if checked % tick_every == 0:
gotify_send(env, "Apply mtimes: progress", f"{checked}/{len(files)} (misses so far: {misses})")
continue
ts, src, extra = chosen
# Upsert DB & apply
db_upsert(cx, path, args.kind, ts, src, extra)
ok = apply_mtime(path, ts, dry_run=args.dry_run)
if ok:
updated += 1
if checked % tick_every == 0:
gotify_send(env, "Apply mtimes: progress", f"{checked}/{len(files)} (updated: {updated})")
cx.commit()
gotify_send(env, "Apply mtimes: done", f"kind={args.kind}, updated={updated}, checked={checked}, misses={misses}")
log(f"Done. kind={args.kind} checked={checked} updated={updated} misses={misses} dry_run={args.dry_run}")
# ---------- Build cache (optional prefetch) ----------
def build_cache_command(args):
env = load_env(args.env)
ensure_db(args.db)
sc = SonarrClient(env_get(env, "SONARR_URL"), env_get(env, "SONARR_API_KEY"))
rc = RadarrClient(env_get(env, "RADARR_URL"), env_get(env, "RADARR_API_KEY"))
tmdb = TMDBClient(env_get(env, "TMDB_API_KEY"), env_get(env, "TMDB_PRIMARY_COUNTRY") or "US")
gotify_send(env, "Cache build: start", f"kind={args.kind}")
roots = expand_roots_from_env(env, args.kind, args.root or [])
files = walk_media_files(roots) if roots else []
if roots:
log(f"Found {len(files)} media file(s) under {', '.join(roots)}")
inserted = 0
checked = 0
with sqlite3.connect(args.db) as cx:
if args.kind == "tv" and sc.ok():
series = sc.series()
total = len(series)
for i, s in enumerate(series, start=1):
log(f"Sonarr: loading episodes for series {s.get('id')} ({i}/{total})…")
eps = sc.episodes_for(s.get("id"))
log(f"Sonarr: loading history for series {s.get('id')}")
hist = sc.history_for(s.get("id"))
epfiles = sc.episode_files_for(s.get("id"))
# We are not mapping specific files here; build-cache is optional.
# If roots provided, we will also pass over files and insert rows using live logic:
# If roots provided, populate using the same chooser logic as apply but without touching mtimes.
if files:
for path in files:
checked += 1
if db_get_row(cx, path) and not args.refresh:
continue
chosen = None
if args.kind == "tv":
if sc.ok():
chosen = sonarr_choose_for_file(sc, path)
if not chosen:
chosen = tmdb_tv_choose(path, tmdb)
else:
if rc.ok():
chosen = radarr_choose_for_file(rc, path)
if not chosen:
chosen = tmdb_movie_choose(path, tmdb)
if chosen:
ts, src, extra = chosen
db_upsert(cx, path, args.kind, ts, src, extra)
inserted += 1
elif args.fallback == "file_mtime":
try:
st = os.stat(path)
ts = int(st.st_mtime)
db_upsert(cx, path, args.kind, ts, "file_mtime", {})
inserted += 1
except Exception:
pass
if checked % 500 == 0:
gotify_send(env, "Cache build: progress", f"{checked}/{len(files)} cached")
cx.commit()
gotify_send(env, "Cache build: done", f"kind={args.kind}, inserted={inserted}, scanned={checked or 'n/a'}")
log(f"Cache build done. kind={args.kind} inserted={inserted} scanned={checked}")
# ---------- CLI ----------
def main():
ap = argparse.ArgumentParser(description="Media date cache & mtime applier (Sonarr/Radarr + TMDB fallback)")
sub = ap.add_subparsers(dest="cmd", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--db", required=True, help="SQLite database path")
common.add_argument("--env", default=None, help="Path to .env (optional, defaults to ./.env if present)")
common.add_argument("--kind", choices=["tv", "movie"], required=True, help="Kind of media this run targets")
common.add_argument("--root", action="append", help="Root path(s) to scan; can repeat. If omitted, uses TV_ROOTS/MOVIE_ROOTS.")
common.add_argument("--refresh", action="store_true", help="Ignore existing DB rows; recompute live")
common.add_argument("--fallback", choices=["none", "file_mtime"], default="none", help="Enable last-resort fallback")
common.add_argument("--dry-run", action="store_true", help="Compute and write DB, but do not change file mtimes")
ap_apply = sub.add_parser("apply", parents=[common], help="Apply mtimes to files, caching choices")
ap_apply.add_argument("--live-on-miss", action="store_true", help="If DB row missing, query Sonarr/Radarr/TMDB live")
ap_build = sub.add_parser("build-cache", parents=[common], help="Build/populate cache without touching mtimes")
# build-cache shares flags
args = ap.parse_args()
if args.cmd == "apply":
apply_command(args)
elif args.cmd == "build-cache":
build_cache_command(args)
else:
ap.print_help()
if __name__ == "__main__":
main()