943 lines
34 KiB
Python
Executable File
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()
|
|
|