upload
This commit is contained in:
Executable
+697
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Media Date Cache (lazy Sonarr/Radarr):
|
||||
- Prefers FIRST import date from history
|
||||
- TV fallback: episode airDateUtc
|
||||
- Movie fallback: digitalRelease, then physicalRelease
|
||||
- Works from DB cache; with --live-on-miss it fetches Sonarr/Radarr lazily per series/movie encounter
|
||||
- Multi-root from .env (TV_ROOTS, MOVIE_ROOTS) and/or --root
|
||||
- Gotify progress notifications
|
||||
"""
|
||||
|
||||
import os, re, sys, time, json, sqlite3, argparse, traceback
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("ERROR: 'requests' is required. In your venv: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# -----------------------------
|
||||
# Utility / ENV
|
||||
# -----------------------------
|
||||
|
||||
ENV_DEFAULT_PATH = os.path.join(os.path.dirname(__file__), ".env")
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def log(msg: str):
|
||||
print(f"[{now_iso()}] {msg}", flush=True)
|
||||
|
||||
def load_env(dotenv_path: str = ENV_DEFAULT_PATH) -> Dict[str, str]:
|
||||
"""Minimal .env loader with inline comment stripping."""
|
||||
env = dict(os.environ)
|
||||
if os.path.exists(dotenv_path):
|
||||
with open(dotenv_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
quoted = (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"'))
|
||||
if not quoted and "#" in v:
|
||||
v = v.split("#", 1)[0].strip()
|
||||
if quoted:
|
||||
v = v[1:-1]
|
||||
env[k] = v
|
||||
return env
|
||||
|
||||
def env_get(env: Dict[str, str], key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
return env.get(key, default)
|
||||
|
||||
def env_get_int(env: Dict[str, str], key: str, default: int) -> int:
|
||||
try:
|
||||
val = env.get(key, None)
|
||||
if val is None:
|
||||
return default
|
||||
return int(str(val).split("#", 1)[0].strip())
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
# -----------------------------
|
||||
# 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:
|
||||
requests.post(
|
||||
url.rstrip("/") + "/message",
|
||||
data={"title": title, "message": message, "priority": prio},
|
||||
headers={"X-Gotify-Key": token},
|
||||
timeout=15,
|
||||
).raise_for_status()
|
||||
except Exception as e:
|
||||
log(f"WARN: gotify failed: {e}")
|
||||
|
||||
# -----------------------------
|
||||
# DB
|
||||
# -----------------------------
|
||||
|
||||
SCHEMA_SQL = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
path TEXT PRIMARY KEY,
|
||||
ts INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
series_id INTEGER NOT NULL,
|
||||
season INTEGER NOT NULL,
|
||||
episode INTEGER NOT NULL,
|
||||
import_ts INTEGER,
|
||||
airdate_ts INTEGER,
|
||||
PRIMARY KEY(series_id, season, episode)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
movie_id INTEGER PRIMARY KEY,
|
||||
import_ts INTEGER,
|
||||
digital_ts INTEGER,
|
||||
physical_ts INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
def db_open(db_path: str) -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
return conn
|
||||
|
||||
def db_upsert_file(conn: sqlite3.Connection, path: str, ts: int, source: str):
|
||||
conn.execute(
|
||||
"INSERT INTO files(path, ts, source, updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(path) DO UPDATE SET ts=excluded.ts, source=excluded.source, updated_at=excluded.updated_at",
|
||||
(path, ts, source, int(time.time())),
|
||||
)
|
||||
|
||||
def db_get_file_ts(conn: sqlite3.Connection, path: str) -> Optional[Tuple[int, str]]:
|
||||
cur = conn.execute("SELECT ts, source FROM files WHERE path=?", (path,))
|
||||
row = cur.fetchone()
|
||||
return (row[0], row[1]) if row else None
|
||||
|
||||
def db_upsert_episode(conn, series_id, season, episode, import_ts, airdate_ts):
|
||||
conn.execute(
|
||||
"INSERT INTO episodes(series_id, season, episode, import_ts, airdate_ts) VALUES(?,?,?,?,?) "
|
||||
"ON CONFLICT(series_id, season, episode) DO UPDATE SET "
|
||||
"import_ts=COALESCE(excluded.import_ts, episodes.import_ts), "
|
||||
"airdate_ts=COALESCE(excluded.airdate_ts, episodes.airdate_ts)",
|
||||
(series_id, season, episode, import_ts, airdate_ts),
|
||||
)
|
||||
|
||||
def db_get_episode(conn, series_id, season, episode):
|
||||
cur = conn.execute(
|
||||
"SELECT import_ts, airdate_ts FROM episodes WHERE series_id=? AND season=? AND episode=?",
|
||||
(series_id, season, episode),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
def db_upsert_movie(conn, movie_id, import_ts, digital_ts, physical_ts):
|
||||
conn.execute(
|
||||
"INSERT INTO movies(movie_id, import_ts, digital_ts, physical_ts) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(movie_id) DO UPDATE SET "
|
||||
"import_ts=COALESCE(excluded.import_ts, movies.import_ts), "
|
||||
"digital_ts=COALESCE(excluded.digital_ts, movies.digital_ts), "
|
||||
"physical_ts=COALESCE(excluded.physical_ts, movies.physical_ts)",
|
||||
(movie_id, import_ts, digital_ts, physical_ts),
|
||||
)
|
||||
|
||||
def db_get_movie(conn, movie_id):
|
||||
cur = conn.execute("SELECT import_ts, digital_ts, physical_ts FROM movies WHERE movie_id=?", (movie_id,))
|
||||
return cur.fetchone()
|
||||
|
||||
# -----------------------------
|
||||
# ARR clients
|
||||
# -----------------------------
|
||||
|
||||
class BaseArr:
|
||||
def __init__(self, base: str, api_key: str, timeout: int = 60):
|
||||
self.base = base.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"X-Api-Key": api_key})
|
||||
self.timeout = timeout
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None):
|
||||
url = self.base + path
|
||||
r = self.session.get(url, params=params or {}, timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
class Sonarr(BaseArr):
|
||||
def all_series(self) -> List[Dict[str, Any]]:
|
||||
data = self._get("/api/v3/series")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
data = self._get("/api/v3/episode", params={"seriesId": series_id})
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def history_page(self, page: int, page_size: int = 250, series_id: Optional[int] = None):
|
||||
params = {
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortDirection": "ascending",
|
||||
"includeEpisode": "true",
|
||||
"includeSeries": "true",
|
||||
}
|
||||
if series_id:
|
||||
params["seriesId"] = series_id
|
||||
data = self._get("/api/v3/history", params=params)
|
||||
if isinstance(data, dict):
|
||||
recs = data.get("records") or data.get("Records") or []
|
||||
total = int(data.get("totalRecords") or data.get("TotalRecords") or 0)
|
||||
return recs, total
|
||||
return [], 0
|
||||
|
||||
def history_all_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
page, size = 1, 250
|
||||
while True:
|
||||
recs, _ = self.history_page(page, size, series_id=series_id)
|
||||
if not recs:
|
||||
break
|
||||
out.extend(recs)
|
||||
if len(recs) < size:
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
class Radarr(BaseArr):
|
||||
def movie_lookup_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
# Radarr supports term={imdbid:tt1234567}
|
||||
data = self._get("/api/v3/movie/lookup", params={"term": f"imdbid:{imdb_id}"})
|
||||
if isinstance(data, list) and data:
|
||||
return data[0]
|
||||
return None
|
||||
|
||||
def movie_by_id(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
data = self._get(f"/api/v3/movie/{movie_id}")
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def history_all_for_movie(self, movie_id: int) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
page, size = 1, 250
|
||||
while True:
|
||||
params = {
|
||||
"page": page,
|
||||
"pageSize": size,
|
||||
"sortDirection": "ascending",
|
||||
"includeMovie": "true",
|
||||
"movieId": movie_id,
|
||||
}
|
||||
data = self._get("/api/v3/history", params=params)
|
||||
recs = data.get("records") if isinstance(data, dict) else []
|
||||
if not recs:
|
||||
break
|
||||
out.extend(recs)
|
||||
if len(recs) < size:
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
# -----------------------------
|
||||
# Parsing helpers
|
||||
# -----------------------------
|
||||
|
||||
IMDB_RE = re.compile(r"\bimdb-(tt\d+)\b", re.IGNORECASE)
|
||||
SXXEYY_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{2})(?:[ ._-]*[Ee-]?(\d{2}))?")
|
||||
SEASON_DIR_RE = re.compile(r"[Ss]eason[ _-]*(\d+)")
|
||||
YEAR_PAREN_RE = re.compile(r"\((\d{4})\)")
|
||||
|
||||
def parse_imdb(s: str) -> Optional[str]:
|
||||
m = IMDB_RE.search(s)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
def parse_series_season_episode(path: str) -> Optional[Tuple[int, List[int]]]:
|
||||
fn = os.path.basename(path)
|
||||
m = SXXEYY_RE.search(fn)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
e1 = int(m.group(2))
|
||||
e2 = m.group(3)
|
||||
eps = [e1] if not e2 else list(range(e1, int(e2) + 1))
|
||||
return season, eps
|
||||
# try a parent piece
|
||||
for part in reversed(path.split(os.sep)):
|
||||
m2 = SXXEYY_RE.search(part)
|
||||
if m2:
|
||||
season = int(m2.group(1))
|
||||
e1 = int(m2.group(2))
|
||||
e2 = m2.group(3)
|
||||
eps = [e1] if not e2 else list(range(e1, int(e2) + 1))
|
||||
return season, eps
|
||||
return None
|
||||
|
||||
def extract_series_folder(path: str) -> str:
|
||||
parts = path.split(os.sep)
|
||||
for i, p in enumerate(parts):
|
||||
if p in ("tv", "tv6"):
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return os.path.basename(os.path.dirname(os.path.dirname(path)))
|
||||
|
||||
def extract_title_and_year(name: str) -> Tuple[str, Optional[int]]:
|
||||
year = None
|
||||
m = YEAR_PAREN_RE.search(name)
|
||||
if m:
|
||||
try: year = int(m.group(1))
|
||||
except: year = None
|
||||
title = name.split("(")[0].strip() if "(" in name else name
|
||||
return title, year
|
||||
|
||||
def to_epoch(ts_str: Optional[str]) -> Optional[int]:
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
s = ts_str.strip()
|
||||
if s.endswith("Z"):
|
||||
s = s.replace("Z", "+00:00")
|
||||
if "+" not in s:
|
||||
s += "+00:00"
|
||||
return int(datetime.fromisoformat(s).timestamp())
|
||||
except Exception:
|
||||
try:
|
||||
dt = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
return int(dt.replace(tzinfo=timezone.utc).timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# -----------------------------
|
||||
# Roots / scanning
|
||||
# -----------------------------
|
||||
|
||||
def split_paths(s: str) -> List[str]:
|
||||
raw = [p.strip() for p in re.split(r"[:,]", s) if p.strip()]
|
||||
return [p for p in raw if os.path.isdir(p)]
|
||||
|
||||
def resolve_roots(kind: str, root_arg: Optional[str], env: Dict[str, str]) -> List[str]:
|
||||
roots: List[str] = []
|
||||
if root_arg and os.path.isdir(root_arg):
|
||||
roots.append(root_arg)
|
||||
if kind == "tv":
|
||||
tv_env = env_get(env, "TV_ROOTS", "")
|
||||
if tv_env:
|
||||
roots.extend(split_paths(tv_env))
|
||||
more = []
|
||||
for r in list(roots):
|
||||
if os.path.isdir(os.path.join(r, "tv")): more.append(os.path.join(r, "tv"))
|
||||
if os.path.isdir(os.path.join(r, "tv6")): more.append(os.path.join(r, "tv6"))
|
||||
roots.extend(more)
|
||||
else:
|
||||
mv_env = env_get(env, "MOVIE_ROOTS", "")
|
||||
if mv_env:
|
||||
roots.extend(split_paths(mv_env))
|
||||
more = []
|
||||
for r in list(roots):
|
||||
if os.path.isdir(os.path.join(r, "movies")): more.append(os.path.join(r, "movies"))
|
||||
if os.path.isdir(os.path.join(r, "movies6")): more.append(os.path.join(r, "movies6"))
|
||||
roots.extend(more)
|
||||
seen, out = set(), []
|
||||
for p in roots:
|
||||
if p not in seen:
|
||||
seen.add(p); out.append(p)
|
||||
return out or ([root_arg] if root_arg else [])
|
||||
|
||||
def iter_media_files(roots: List[str]):
|
||||
exts = {".mkv", ".mp4", ".avi", ".m4v", ".mov"}
|
||||
for base in roots:
|
||||
if not base or not os.path.isdir(base): continue
|
||||
for dirpath, _, filenames in os.walk(base):
|
||||
for fn in filenames:
|
||||
if os.path.splitext(fn)[1].lower() in exts:
|
||||
yield os.path.join(dirpath, fn)
|
||||
|
||||
# -----------------------------
|
||||
# Lazy Sonarr & Radarr helpers
|
||||
# -----------------------------
|
||||
|
||||
def build_sonarr_index(env: Dict[str, str]) -> Tuple[Sonarr, Dict[int, Dict[str, Any]], Dict[str, int], Dict[Tuple[str, Optional[int]], int]]:
|
||||
url = env_get(env, "SONARR_URL", "")
|
||||
key = env_get(env, "SONARR_API_KEY", "")
|
||||
if not url or not key:
|
||||
raise RuntimeError("SONARR_URL/SONARR_API_KEY not set")
|
||||
timeout = env_get_int(env, "ARR_TIMEOUT", 60)
|
||||
client = Sonarr(url, key, timeout=timeout)
|
||||
|
||||
log("Sonarr: loading series index…")
|
||||
series = client.all_series()
|
||||
series_by_id: Dict[int, Dict[str, Any]] = {}
|
||||
by_imdb: Dict[str, int] = {}
|
||||
title_year_map: Dict[Tuple[str, Optional[int]], int] = {}
|
||||
for s in series:
|
||||
sid = int(s.get("id"))
|
||||
series_by_id[sid] = s
|
||||
imdb = (s.get("imdbId") or "").lower().strip()
|
||||
if imdb: by_imdb[imdb] = sid
|
||||
title = (s.get("title") or "").strip().lower()
|
||||
year = s.get("year")
|
||||
title_year_map[(title, year)] = sid
|
||||
log(f"Sonarr: indexed {len(series_by_id)} series")
|
||||
return client, series_by_id, by_imdb, title_year_map
|
||||
|
||||
def get_series_id_for_path(path: str, series_by_imdb: Dict[str, int], title_year_map: Dict[Tuple[str, Optional[int]], int]) -> Optional[int]:
|
||||
series_folder = extract_series_folder(path)
|
||||
imdb = parse_imdb(series_folder)
|
||||
if imdb and imdb in series_by_imdb:
|
||||
return series_by_imdb[imdb]
|
||||
title, year = extract_title_and_year(series_folder)
|
||||
return title_year_map.get((title.strip().lower(), year))
|
||||
|
||||
def tv_preferred_ts_lazy(
|
||||
path: str,
|
||||
conn: sqlite3.Connection,
|
||||
client: Sonarr,
|
||||
series_by_id: Dict[int, Dict[str, Any]],
|
||||
series_by_imdb: Dict[str, int],
|
||||
title_year_map: Dict[Tuple[str, Optional[int]], int],
|
||||
episodes_cache: Dict[int, Dict[Tuple[int, int], Dict[str, Any]]],
|
||||
earliest_import_cache: Dict[int, Dict[int, int]],
|
||||
) -> Optional[Tuple[int, str]]:
|
||||
sid = get_series_id_for_path(path, series_by_imdb, title_year_map)
|
||||
if not sid: return None
|
||||
se = parse_series_season_episode(path)
|
||||
if not se: return None
|
||||
season, eps = se
|
||||
|
||||
# Fetch episodes for this series lazily
|
||||
if sid not in episodes_cache:
|
||||
log(f"Sonarr: loading episodes for series {sid}…")
|
||||
ep_list = client.episodes_for_series(sid)
|
||||
m: Dict[Tuple[int, int], Dict[str, Any]] = {}
|
||||
for e in ep_list:
|
||||
key = (int(e.get("seasonNumber", -1)), int(e.get("episodeNumber", -1)))
|
||||
m[key] = e
|
||||
episodes_cache[sid] = m
|
||||
|
||||
# Fetch history earliest-import for this series lazily
|
||||
if sid not in earliest_import_cache:
|
||||
log(f"Sonarr: loading history for series {sid}…")
|
||||
recs = client.history_all_for_series(sid)
|
||||
by_epid: Dict[int, int] = {}
|
||||
for r in recs:
|
||||
et = str(r.get("eventType") or "").lower()
|
||||
if "import" not in et:
|
||||
continue
|
||||
ep = r.get("episode") or {}
|
||||
eid = ep.get("id")
|
||||
date = to_epoch(r.get("date"))
|
||||
if eid and date:
|
||||
cur = by_epid.get(eid)
|
||||
by_epid[eid] = min(cur, date) if cur else date
|
||||
earliest_import_cache[sid] = by_epid
|
||||
|
||||
epmap = episodes_cache.get(sid, {})
|
||||
by_epid = earliest_import_cache.get(sid, {})
|
||||
|
||||
import_ts_list: List[int] = []
|
||||
air_ts_list: List[int] = []
|
||||
|
||||
for e in eps:
|
||||
epi = epmap.get((season, e))
|
||||
if not epi: continue
|
||||
eid = epi.get("id")
|
||||
air_ts = to_epoch(epi.get("airDateUtc") or epi.get("airDate"))
|
||||
if air_ts: air_ts_list.append(air_ts)
|
||||
if eid and eid in by_epid:
|
||||
import_ts_list.append(by_epid[eid])
|
||||
|
||||
# persist to episode table for future runs
|
||||
db_upsert_episode(conn, sid, season, e, by_epid.get(eid), air_ts)
|
||||
|
||||
if import_ts_list:
|
||||
return min(import_ts_list), "history"
|
||||
if air_ts_list:
|
||||
return min(air_ts_list), "airdate"
|
||||
return None
|
||||
|
||||
def movie_preferred_ts_lazy(
|
||||
path: str,
|
||||
conn: sqlite3.Connection,
|
||||
client: Radarr,
|
||||
) -> Optional[Tuple[int, str]]:
|
||||
# Identify movie folder under movies/movies6
|
||||
parts = path.split(os.sep)
|
||||
movie_folder = ""
|
||||
for i, p in enumerate(parts):
|
||||
if p in ("movies", "movies6") and i + 1 < len(parts):
|
||||
movie_folder = parts[i + 1]
|
||||
break
|
||||
if not movie_folder:
|
||||
movie_folder = os.path.basename(os.path.dirname(path))
|
||||
imdb = parse_imdb(movie_folder)
|
||||
title, year = extract_title_and_year(movie_folder)
|
||||
|
||||
movie: Optional[Dict[str, Any]] = None
|
||||
if imdb:
|
||||
movie = client.movie_lookup_by_imdb(imdb)
|
||||
# If no imdb or lookup failed, try guessing by path title/year is not implemented lazily (to avoid /api/v3/movie bulk pull).
|
||||
if not movie:
|
||||
return None
|
||||
|
||||
movie_id = movie.get("id")
|
||||
if not movie_id:
|
||||
return None
|
||||
|
||||
# Build earliest import from history for THIS movie only
|
||||
recs = client.history_all_for_movie(movie_id)
|
||||
import_ts: Optional[int] = None
|
||||
for r in recs:
|
||||
et = str(r.get("eventType") or "").lower()
|
||||
if "import" in et:
|
||||
dt = to_epoch(r.get("date"))
|
||||
import_ts = min(import_ts, dt) if (import_ts and dt) else (dt or import_ts)
|
||||
|
||||
# Preferred: import; fallback digital -> physical
|
||||
digital = to_epoch(movie.get("digitalRelease") or movie.get("digitalReleaseDate") or movie.get("inCinemas"))
|
||||
physical = to_epoch(movie.get("physicalRelease") or movie.get("physicalReleaseDate"))
|
||||
|
||||
# persist to movie table for future runs
|
||||
db_upsert_movie(conn, movie_id, import_ts, digital, physical)
|
||||
|
||||
if import_ts: return import_ts, "history"
|
||||
if digital: return digital, "digital"
|
||||
if physical: return physical, "physical"
|
||||
return None
|
||||
|
||||
# -----------------------------
|
||||
# Apply mtimes
|
||||
# -----------------------------
|
||||
|
||||
def apply_mtime(path: str, ts: int):
|
||||
try:
|
||||
os.utime(path, (ts, ts), follow_symlinks=False)
|
||||
except Exception:
|
||||
os.utime(path, (ts, ts))
|
||||
|
||||
# -----------------------------
|
||||
# Commands
|
||||
# -----------------------------
|
||||
|
||||
def cmd_apply(args, env):
|
||||
db = db_open(args.db)
|
||||
kind = args.kind
|
||||
gotify_send(env, "Apply mtimes: start", f"kind={kind}")
|
||||
|
||||
roots = resolve_roots(kind, args.root, env)
|
||||
if not roots:
|
||||
raise SystemExit("No valid roots. Set --root or TV_ROOTS/MOVIE_ROOTS in .env")
|
||||
|
||||
files = list(iter_media_files(roots))
|
||||
log(f"Found {len(files)} media file(s) under {', '.join(roots)}")
|
||||
|
||||
# Lazy ARR setup
|
||||
sonarr_client = None
|
||||
radarr_client = None
|
||||
series_by_id: Dict[int, Dict[str, Any]] = {}
|
||||
series_by_imdb: Dict[str, int] = {}
|
||||
title_year_map: Dict[Tuple[str, Optional[int]], int] = {}
|
||||
episodes_cache: Dict[int, Dict[Tuple[int, int], Dict[str, Any]]] = {}
|
||||
earliest_import_cache: Dict[int, Dict[int, int]] = {}
|
||||
|
||||
if args.live_on_miss:
|
||||
if kind == "tv":
|
||||
log("Initializing Sonarr (series index only)…")
|
||||
client, s_by_id, s_by_imdb, tmap = build_sonarr_index(env)
|
||||
sonarr_client = client
|
||||
series_by_id = s_by_id
|
||||
series_by_imdb = s_by_imdb
|
||||
title_year_map = tmap
|
||||
else:
|
||||
url = env_get(env, "RADARR_URL", "")
|
||||
key = env_get(env, "RADARR_API_KEY", "")
|
||||
if not url or not key:
|
||||
raise SystemExit("RADARR_URL/RADARR_API_KEY not set")
|
||||
timeout = env_get_int(env, "ARR_TIMEOUT", 60)
|
||||
radarr_client = Radarr(url, key, timeout=timeout)
|
||||
log("Initialized Radarr client (lazy per-movie lookups).")
|
||||
|
||||
# Progress
|
||||
notify_every = env_get_int(env, "GOTIFY_TICK_EVERY", 500)
|
||||
last_notify = 0
|
||||
done = skipped = updated = failures = 0
|
||||
|
||||
for idx, path in enumerate(files, 1):
|
||||
try:
|
||||
# cached file path?
|
||||
cached = db_get_file_ts(db, path)
|
||||
if cached and not args.reapply_all:
|
||||
skipped += 1
|
||||
else:
|
||||
ts_src: Optional[Tuple[int, str]] = None
|
||||
if kind == "tv":
|
||||
if sonarr_client:
|
||||
ts_src = tv_preferred_ts_lazy(
|
||||
path, db, sonarr_client,
|
||||
series_by_id, series_by_imdb, title_year_map,
|
||||
episodes_cache, earliest_import_cache
|
||||
)
|
||||
else:
|
||||
if radarr_client:
|
||||
ts_src = movie_preferred_ts_lazy(path, db, radarr_client)
|
||||
|
||||
if ts_src:
|
||||
ts, src = ts_src
|
||||
db_upsert_file(db, path, ts, src)
|
||||
apply_mtime(path, ts)
|
||||
updated += 1
|
||||
else:
|
||||
log(f"WARN: no import/release date for {path}")
|
||||
failures += 1
|
||||
done += 1
|
||||
|
||||
if notify_every > 0 and done - last_notify >= notify_every:
|
||||
gotify_send(env, f"Apply {kind}: progress",
|
||||
f"{done}/{len(files)} files (updated {updated}, skipped {skipped}, failures {failures})")
|
||||
last_notify = done
|
||||
except Exception as e:
|
||||
failures += 1
|
||||
log(f"WARN: failed on {path}: {e}")
|
||||
# traceback.print_exc()
|
||||
|
||||
db.commit()
|
||||
gotify_send(env, f"Apply mtimes: done ({kind})", f"total={done} updated={updated} skipped={skipped} failures={failures}")
|
||||
log(f"Apply complete: total={done} updated={updated} skipped={skipped} failures={failures}")
|
||||
db.close()
|
||||
|
||||
def cmd_build_cache(args, env):
|
||||
# Optional now; lazy apply fills DB as it goes.
|
||||
# Keeping a light build for people who still want a pre-warm step.
|
||||
kind = args.kind
|
||||
db = db_open(args.db)
|
||||
if kind == "tv":
|
||||
client, series_by_id, series_by_imdb, title_year_map = build_sonarr_index(env)
|
||||
wrote = 0
|
||||
for sid in series_by_id.keys():
|
||||
eps = client.episodes_for_series(sid)
|
||||
recs = client.history_all_for_series(sid)
|
||||
by_epid: Dict[int, int] = {}
|
||||
for r in recs:
|
||||
et = str(r.get("eventType") or "").lower()
|
||||
if "import" not in et: continue
|
||||
ep = r.get("episode") or {}
|
||||
eid = ep.get("id"); dt = to_epoch(r.get("date"))
|
||||
if eid and dt:
|
||||
cur = by_epid.get(eid)
|
||||
by_epid[eid] = min(cur, dt) if cur else dt
|
||||
for e in eps:
|
||||
season = int(e.get("seasonNumber", -1))
|
||||
epno = int(e.get("episodeNumber", -1))
|
||||
eid = e.get("id")
|
||||
air = to_epoch(e.get("airDateUtc") or e.get("airDate"))
|
||||
imp = by_epid.get(eid)
|
||||
db_upsert_episode(db, sid, season, epno, imp, air)
|
||||
wrote += 1
|
||||
if wrote % 2000 == 0:
|
||||
db.commit()
|
||||
db.commit()
|
||||
log(f"Episode cache rows upserted: {wrote}")
|
||||
else:
|
||||
url = env_get(env, "RADARR_URL", "")
|
||||
key = env_get(env, "RADARR_API_KEY", "")
|
||||
if not url or not key:
|
||||
raise SystemExit("RADARR_URL/RADARR_API_KEY not set")
|
||||
timeout = env_get_int(env, "ARR_TIMEOUT", 60)
|
||||
rad = Radarr(url, key, timeout=timeout)
|
||||
log("Movie cache build (lazy is preferred; this just verifies connectivity).")
|
||||
gotify_send(env, "Cache build: done", f"kind={kind}")
|
||||
db.close()
|
||||
|
||||
# -----------------------------
|
||||
# CLI
|
||||
# -----------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Media date cache and mtime applier (lazy Sonarr/Radarr).")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_build = sub.add_parser("build-cache", help="(Optional) Prewarm caches")
|
||||
p_build.add_argument("--kind", choices=["tv", "movie"], required=True)
|
||||
p_build.add_argument("--db", required=True)
|
||||
|
||||
p_apply = sub.add_parser("apply", help="Apply file mtimes; with --live-on-miss it lazily queries Sonarr/Radarr for misses")
|
||||
p_apply.add_argument("--kind", choices=["tv", "movie"], required=True)
|
||||
p_apply.add_argument("--db", required=True)
|
||||
p_apply.add_argument("--root", help="Root path; if omitted uses TV_ROOTS/MOVIE_ROOTS from .env")
|
||||
p_apply.add_argument("--live-on-miss", action="store_true")
|
||||
p_apply.add_argument("--reapply-all", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
env = load_env()
|
||||
|
||||
if args.cmd == "build-cache":
|
||||
gotify_send(env, "Cache build: start", f"kind={args.kind}")
|
||||
cmd_build_cache(args, env)
|
||||
else:
|
||||
cmd_apply(args, env)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user