upload
This commit is contained in:
Executable
+942
@@ -0,0 +1,942 @@
|
||||
#!/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()
|
||||
|
||||
Reference in New Issue
Block a user