1960 lines
78 KiB
Python
1960 lines
78 KiB
Python
#!/usr/bin/env python3
|
|
# app.py - NFOGuard Webhook Server (TV + Movies)
|
|
#
|
|
# Features:
|
|
# - TV via existing media_date_cache.apply_series (Sonarr webhooks + manual scan)
|
|
# - Movies with robust <dateadded> logic and NFO upsert:
|
|
# * Priority configurable: import_then_digital | digital_then_import
|
|
# * Poll policy: always | if_missing | never
|
|
# * Digital date candidates: Radarr, TMDB, Jellyseerr (optional), OMDb (optional) -> pick earliest
|
|
# * Digital sanity check (re-release guard): compare to cinema/import/min and fallback
|
|
# * Update policy: backfill_only | overwrite (+ preserve flags in backfill mode)
|
|
# * Pretty XML, ensures <dateadded>, <lockdata>, <premiered>, <year>, footer comments, uniqueid imdb
|
|
# * Optional mtimes update to match chosen <dateadded>
|
|
#
|
|
# Endpoints:
|
|
# - POST /webhook/sonarr
|
|
# - POST /webhook/radarr
|
|
# - POST /manual/scan (scan_type=both|tv|movies, optional path=...)
|
|
# - POST /manual/scan/tv
|
|
# - POST /manual/scan/movies
|
|
# - GET /health
|
|
# - GET /stats
|
|
# - GET /batch/status
|
|
#
|
|
# Env (highlights):
|
|
# MANAGER_BRAND (default NFOGuard)
|
|
# MOVIE_PRIORITY=import_then_digital|digital_then_import
|
|
# MOVIE_POLL_MODE=always|if_missing|never
|
|
# MOVIE_DATE_UPDATE_MODE=backfill_only|overwrite
|
|
# MOVIE_PRESERVE_RADARR_IMPORT=true|false
|
|
# MOVIE_PRESERVE_EXISTING=true|false
|
|
# MOVIE_DIGITAL_SANITY_CHECK=true|false
|
|
# MOVIE_DIGITAL_SANITY_REF=cinema|import|min
|
|
# MOVIE_DIGITAL_MAX_LAG_DAYS=365
|
|
# MOVIE_DIGITAL_INVALID_FALLBACK=import|cinema
|
|
# TMDB_API_KEY / TMDB_PRIMARY_COUNTRY
|
|
# OMDB_API_KEY
|
|
# JELLYSEERR_URL / JELLYSEERR_API_KEY
|
|
# RADARR_URL / RADARR_API_KEY / RADARR_TIMEOUT / RADARR_RETRIES
|
|
# SONARR_URL / SONARR_API_KEY (TV pathing only; series handled in media_date_cache)
|
|
# TV_PATHS, MOVIE_PATHS
|
|
# MANAGE_NFO, FIX_DIR_MTIMES, LOCK_METADATA, DEBUG
|
|
# BATCH_DELAY, MAX_CONCURRENT_SERIES
|
|
#
|
|
# Run: uvicorn app:app --host 0.0.0.0 --port 8080
|
|
|
|
import os
|
|
import json
|
|
import asyncio
|
|
import glob
|
|
import re
|
|
import logging
|
|
import logging.handlers
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone, timedelta
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
|
from pydantic import BaseModel
|
|
from typing import Optional, Dict, Any, List, Set, Tuple
|
|
import sqlite3
|
|
import uvicorn
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import xml.etree.ElementTree as ET
|
|
from urllib.parse import urlencode, urljoin, quote
|
|
from urllib.request import Request as UrlRequest, urlopen
|
|
from urllib.error import URLError, HTTPError
|
|
import time
|
|
|
|
# Import TV helpers from your existing module
|
|
from media_date_cache import (
|
|
db_connect, apply_series, parse_imdb_from_path,
|
|
_log as _original_log, _iso_now, SonarrClient
|
|
)
|
|
|
|
# ---------------------------
|
|
# Enhanced logging with file support
|
|
# ---------------------------
|
|
|
|
# Global file logger
|
|
_file_logger = None
|
|
|
|
def _setup_file_logging():
|
|
"""Setup file logging for NFOGuard"""
|
|
global _file_logger
|
|
if _file_logger is not None:
|
|
return _file_logger
|
|
|
|
# Create logs directory
|
|
log_dir = Path("/app/data/logs")
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup rotating file handler
|
|
log_file = log_dir / "nfoguard.log"
|
|
_file_logger = logging.getLogger("NFOGuard")
|
|
_file_logger.setLevel(logging.DEBUG)
|
|
|
|
# Rotating file handler (50MB max, 3 backups - reduced rotation frequency)
|
|
file_handler = logging.handlers.RotatingFileHandler(
|
|
log_file, maxBytes=50*1024*1024, backupCount=3
|
|
)
|
|
|
|
# Detailed format for file logging
|
|
formatter = logging.Formatter(
|
|
'[%(asctime)s] %(levelname)s: %(message)s',
|
|
datefmt='%Y-%m-%dT%H:%M:%S+00:00'
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
_file_logger.addHandler(file_handler)
|
|
|
|
return _file_logger
|
|
|
|
def _log(level: str, msg: str):
|
|
"""Enhanced logging that writes to both console and file"""
|
|
# Call original log for console output
|
|
_original_log(level, msg)
|
|
|
|
# Also log to file
|
|
try:
|
|
file_logger = _setup_file_logging()
|
|
if level == "DEBUG":
|
|
file_logger.debug(msg)
|
|
elif level == "INFO":
|
|
file_logger.info(msg)
|
|
elif level == "WARNING":
|
|
file_logger.warning(msg)
|
|
elif level == "ERROR":
|
|
file_logger.error(msg)
|
|
else:
|
|
file_logger.info(f"{level}: {msg}")
|
|
except Exception as e:
|
|
# Don't let logging errors break the app
|
|
print(f"File logging error: {e}")
|
|
|
|
# Initialize file logging on startup
|
|
_setup_file_logging()
|
|
|
|
# ---------------------------
|
|
# Models
|
|
# ---------------------------
|
|
|
|
class SonarrWebhook(BaseModel):
|
|
eventType: str
|
|
series: Optional[Dict[str, Any]] = None
|
|
episodes: Optional[list] = []
|
|
episodeFile: Optional[Dict[str, Any]] = None
|
|
isUpgrade: Optional[bool] = False
|
|
movie: Optional[Dict[str, Any]] = None
|
|
remoteMovie: Optional[Dict[str, Any]] = None
|
|
deletedFiles: Optional[list] = []
|
|
|
|
class Config:
|
|
extra = "allow"
|
|
|
|
class RadarrWebhook(BaseModel):
|
|
eventType: str
|
|
movie: Optional[Dict[str, Any]] = None
|
|
movieFile: Optional[Dict[str, Any]] = None
|
|
isUpgrade: Optional[bool] = False
|
|
deletedFiles: Optional[list] = []
|
|
remoteMovie: Optional[Dict[str, Any]] = None
|
|
renamedMovieFiles: Optional[List[Dict[str, Any]]] = None # on Rename
|
|
|
|
class Config:
|
|
extra = "allow"
|
|
|
|
class HealthResponse(BaseModel):
|
|
status: str
|
|
version: str
|
|
uptime: str
|
|
database_status: str
|
|
|
|
# ---------------------------
|
|
# App & Config
|
|
# ---------------------------
|
|
|
|
app = FastAPI(
|
|
title="NFOGuard Webhook Server",
|
|
description="Locks true import dates; manages NFOs and mtimes for TV & Movies",
|
|
version="1.7.0",
|
|
)
|
|
|
|
start_time = datetime.now(timezone.utc)
|
|
db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
|
|
|
|
def _norm(s: str) -> str:
|
|
return (s or "").strip().lower()
|
|
|
|
def _bool_env(name: str, default: bool) -> bool:
|
|
v = os.environ.get(name)
|
|
if v is None:
|
|
return default
|
|
return _norm(v) in ("1", "true", "yes", "y", "on")
|
|
|
|
class WebhookConfig:
|
|
def __init__(self):
|
|
tv_paths_str = os.environ.get("TV_PATHS", os.environ.get("MEDIA_PATH", "/media/tv,/media/tv6"))
|
|
self.tv_paths = [Path(p.strip()) for p in tv_paths_str.split(",") if p.strip()]
|
|
|
|
movie_paths_str = os.environ.get("MOVIE_PATHS", "/media/movies,/media/movies6")
|
|
self.movie_paths = [Path(p.strip()) for p in movie_paths_str.split(",") if p.strip()]
|
|
|
|
self.manage_nfo = _bool_env("MANAGE_NFO", True)
|
|
self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True)
|
|
self.lock_metadata = _bool_env("LOCK_METADATA", True)
|
|
self.debug = _bool_env("DEBUG", False)
|
|
|
|
self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0"))
|
|
self.max_concurrent_series = int(os.environ.get("MAX_CONCURRENT_SERIES", "3"))
|
|
|
|
self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
|
|
|
|
# Radarr
|
|
self.radarr_url = (os.environ.get("RADARR_URL", "") or "").rstrip("/")
|
|
self.radarr_api_key = os.environ.get("RADARR_API_KEY", "") or ""
|
|
self.radarr_timeout = int(os.environ.get("RADARR_TIMEOUT", "45"))
|
|
self.radarr_retries = int(os.environ.get("RADARR_RETRIES", "3"))
|
|
|
|
# Optional externals
|
|
self.tmdb_api_key = os.environ.get("TMDB_API_KEY") or ""
|
|
self.tmdb_country = (os.environ.get("TMDB_PRIMARY_COUNTRY") or "US").upper()
|
|
self.omdb_api_key = os.environ.get("OMDB_API_KEY") or ""
|
|
self.jelly_url = (os.environ.get("JELLYSEERR_URL") or "").rstrip("/")
|
|
self.jelly_api = os.environ.get("JELLYSEERR_API_KEY") or ""
|
|
|
|
# Legacy (logged only)
|
|
raw_strategy = _norm(os.environ.get("MOVIE_DATE_STRATEGY", "radarr_import"))
|
|
aliases = {
|
|
"import": "radarr_import",
|
|
"radarr": "radarr_import",
|
|
"digital_release": "digital",
|
|
"physical_release": "physical",
|
|
"in_cinemas": "cinema",
|
|
"theatrical": "cinema",
|
|
"mtime": "file_mtime",
|
|
}
|
|
self.movie_date_strategy = aliases.get(raw_strategy, raw_strategy)
|
|
|
|
# Priority
|
|
self.movie_priority = _norm(os.environ.get("MOVIE_PRIORITY", "import_then_digital"))
|
|
if self.movie_priority not in ("import_then_digital", "digital_then_import"):
|
|
self.movie_priority = "import_then_digital"
|
|
|
|
# Poll policy
|
|
self.movie_poll_mode = _norm(os.environ.get("MOVIE_POLL_MODE", "always")) # always|if_missing|never
|
|
if self.movie_poll_mode not in ("always", "if_missing", "never"):
|
|
self.movie_poll_mode = "always"
|
|
|
|
# Update policy
|
|
self.movie_date_update_mode = _norm(os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only"))
|
|
self.movie_preserve_radarr_import = _bool_env("MOVIE_PRESERVE_RADARR_IMPORT", True)
|
|
self.movie_preserve_existing = _bool_env("MOVIE_PRESERVE_EXISTING", True)
|
|
|
|
# Digital sanity check
|
|
self.movie_digital_sanity_check = _bool_env("MOVIE_DIGITAL_SANITY_CHECK", False)
|
|
self.movie_digital_max_lag_days = int(os.environ.get("MOVIE_DIGITAL_MAX_LAG_DAYS", "365"))
|
|
self.movie_digital_sanity_ref = _norm(os.environ.get("MOVIE_DIGITAL_SANITY_REF", "cinema")) # cinema|import|min
|
|
if self.movie_digital_sanity_ref not in ("cinema", "import", "min"):
|
|
self.movie_digital_sanity_ref = "cinema"
|
|
self.movie_digital_invalid_fallback = _norm(os.environ.get("MOVIE_DIGITAL_INVALID_FALLBACK", "import")) # import|cinema
|
|
if self.movie_digital_invalid_fallback not in ("import", "cinema"):
|
|
self.movie_digital_invalid_fallback = "import"
|
|
|
|
config = WebhookConfig()
|
|
|
|
# ---------------------------
|
|
# DB schema (movies)
|
|
# ---------------------------
|
|
|
|
MOVIE_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS movies (
|
|
imdb_id TEXT PRIMARY KEY,
|
|
movie_path TEXT NOT NULL,
|
|
last_applied TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS movie_dates (
|
|
imdb_id TEXT PRIMARY KEY,
|
|
released TEXT,
|
|
dateadded TEXT,
|
|
source TEXT,
|
|
has_video_file INTEGER DEFAULT 0
|
|
);
|
|
"""
|
|
|
|
def ensure_movie_tables(conn: sqlite3.Connection):
|
|
try:
|
|
result = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='movies'").fetchone()
|
|
if not result:
|
|
_log("INFO", "Creating movie tables in database")
|
|
conn.executescript(MOVIE_SCHEMA)
|
|
conn.commit()
|
|
except sqlite3.Error as e:
|
|
_log("ERROR", f"Failed to create movie tables: {e}")
|
|
|
|
# ---------------------------
|
|
# HTTP helper
|
|
# ---------------------------
|
|
|
|
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None) -> Optional[Any]:
|
|
try:
|
|
req = UrlRequest(url, headers=headers or {"Accept": "application/json"})
|
|
with urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except Exception as e:
|
|
_log("WARNING", f"GET {url} failed: {e}")
|
|
return None
|
|
|
|
# ---------------------------
|
|
# Radarr client
|
|
# ---------------------------
|
|
|
|
class RadarrClient:
|
|
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
|
self.base_url = base_url.rstrip("/") + "/"
|
|
self.api_key = api_key
|
|
self.timeout = timeout
|
|
self.retries = max(0, retries)
|
|
|
|
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
|
if not self.api_key:
|
|
return None
|
|
attempt = 0
|
|
last_err = None
|
|
while attempt <= self.retries:
|
|
try:
|
|
params = params or {}
|
|
params["apikey"] = self.api_key
|
|
url = urljoin(self.base_url, path.lstrip("?").lstrip("/"))
|
|
if params:
|
|
url = url + ("&" if "?" in url else "?") + urlencode(params)
|
|
|
|
_log("DEBUG", f"Radarr API Request: {url}")
|
|
req = UrlRequest(url, headers={"Accept": "application/json"})
|
|
with urlopen(req, timeout=self.timeout) as resp:
|
|
data = resp.read().decode("utf-8")
|
|
result = json.loads(data)
|
|
_log("DEBUG", f"Radarr API Response: {json.dumps(result, indent=2) if isinstance(result, (dict, list)) else result}")
|
|
return result
|
|
except (URLError, HTTPError, json.JSONDecodeError) as e:
|
|
last_err = e
|
|
_log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}")
|
|
time.sleep(min(2 ** attempt, 5))
|
|
attempt += 1
|
|
_log("WARNING", f"Radarr GET {path} failed after retries: {last_err}")
|
|
return None
|
|
|
|
def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
|
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
|
_log("DEBUG", f"Radarr API: Looking up movie by IMDb ID: {imdb_id}")
|
|
|
|
# Try direct movie search with IMDb filter (even if unreliable)
|
|
res = self._get("/api/v3/movie", {"imdbId": imdb_id})
|
|
if isinstance(res, list) and len(res) > 0:
|
|
# CRITICAL: Validate that returned movie actually matches requested IMDb ID
|
|
for movie in res:
|
|
movie_imdb = movie.get("imdbId", "").lower()
|
|
if movie_imdb == imdb_id.lower():
|
|
_log("DEBUG", f"Radarr API: Found exact match: {movie.get('title')} (ID: {movie.get('id')})")
|
|
return movie
|
|
|
|
# Log the mismatch for debugging
|
|
returned_ids = [m.get("imdbId", "None") for m in res[:3]] # Show first 3
|
|
_log("ERROR", f"Radarr API returned WRONG movies for {imdb_id}! Got: {returned_ids}")
|
|
_log("DEBUG", f"Will reject mismatched results and try lookup endpoint")
|
|
|
|
# Fallback: Try lookup endpoint which might be more reliable
|
|
res = self._get("/api/v3/movie/lookup/imdb", {"imdbId": imdb_id})
|
|
if isinstance(res, dict) and res.get("imdbId", "").lower() == imdb_id.lower():
|
|
_log("DEBUG", f"Radarr API: Found movie via lookup: {res.get('title')} (ID: {res.get('id')})")
|
|
return res
|
|
|
|
_log("WARNING", f"Radarr API: No valid movie found for IMDb ID: {imdb_id}")
|
|
return None
|
|
|
|
def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
|
|
res = self._get("/api/v3/moviefile", {"movieId": movie_id})
|
|
return res if isinstance(res, list) else []
|
|
|
|
def earliest_dateadded(self, movie_id: int) -> Optional[str]:
|
|
files = self.movie_files(movie_id)
|
|
best = None
|
|
for f in files:
|
|
da = f.get("dateAdded")
|
|
if not da:
|
|
continue
|
|
try:
|
|
dt = datetime.fromisoformat(da.replace("Z", "+00:00"))
|
|
if best is None or dt < best:
|
|
best = dt
|
|
except Exception:
|
|
continue
|
|
if best:
|
|
return best.astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
return None
|
|
|
|
def earliest_import_event(self, movie_id: int) -> Optional[str]:
|
|
# Collect all history pages to ensure we don't miss early import events
|
|
all_items = []
|
|
page = 1
|
|
page_size = 1000
|
|
|
|
_log("INFO", f"Starting history lookup for movie_id {movie_id}")
|
|
|
|
while True:
|
|
data = self._get("/api/v3/history", {
|
|
"movieId": movie_id, "page": page, "pageSize": page_size,
|
|
"sortKey": "date", "sortDirection": "ascending"
|
|
})
|
|
if not data:
|
|
break
|
|
|
|
items = data if isinstance(data, list) else data.get("records") or []
|
|
if not items:
|
|
break
|
|
|
|
all_items.extend(items)
|
|
_log("DEBUG", f"Page {page}: Found {len(items)} history items for movie_id {movie_id}")
|
|
|
|
# If we got less than pageSize, we've reached the end
|
|
if len(items) < page_size:
|
|
break
|
|
|
|
page += 1
|
|
# Safety valve - don't go crazy with pagination
|
|
if page > 10:
|
|
_log("WARNING", f"Stopped pagination at page 10 for movie_id {movie_id} - may have missed some history")
|
|
break
|
|
|
|
_log("INFO", f"Found {len(all_items)} total history items across {page} pages for movie_id {movie_id}")
|
|
|
|
# Log ALL events first for debugging
|
|
_log("INFO", f"=== COMPLETE HISTORY ANALYSIS for movie_id {movie_id} ===")
|
|
for i, it in enumerate(all_items):
|
|
ev = (it.get("eventType") or it.get("type") or "").lower()
|
|
date_str = it.get("date", "NO_DATE")
|
|
event_data = it.get('data', {})
|
|
source_path = event_data.get('sourcePath', '') or event_data.get('path', '') or event_data.get('sourceTitle', '')
|
|
|
|
_log("INFO", f"Event #{i+1}: type='{ev}', date='{date_str}', sourcePath='{source_path}'")
|
|
_log("DEBUG", f" Full data: {json.dumps(event_data, indent=2) if event_data else 'No data'}")
|
|
|
|
first_grab = None
|
|
earliest_real_import = None
|
|
|
|
for it in all_items:
|
|
ev = (it.get("eventType") or it.get("type") or "").lower()
|
|
date_iso = None
|
|
if it.get("date"):
|
|
try:
|
|
date_iso = datetime.fromisoformat(it["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
except Exception:
|
|
date_iso = None
|
|
|
|
# Expanded event types for Radarr v4/v5 compatibility
|
|
import_events = {
|
|
"downloadfolderimported", "moviefileimported", "imported",
|
|
"downloadimported", "fileimported", "movieimported"
|
|
}
|
|
|
|
if ev in import_events:
|
|
# Check if this is a real import (from downloads) vs existing file rename
|
|
event_data = it.get('data', {})
|
|
source_path = event_data.get('sourcePath', '') or event_data.get('path', '') or event_data.get('sourceTitle', '')
|
|
|
|
_log("INFO", f"Analyzing import event '{ev}' with source: '{source_path}'")
|
|
|
|
# Real imports typically come from download folders
|
|
download_indicators = [
|
|
'/downloads/', '/download/', '/completed/', '/nzbs/', '/torrents/',
|
|
'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
|
|
'/radarr/', 'completed/' # Add more specific patterns
|
|
]
|
|
|
|
is_real_import = any(indicator in source_path.lower() for indicator in download_indicators)
|
|
|
|
_log("INFO", f"Import analysis: is_real_import={is_real_import}, date={date_iso}")
|
|
|
|
if is_real_import and date_iso:
|
|
_log("INFO", f"✅ FOUND REAL IMPORT EVENT '{ev}' from downloads at {date_iso} (source: {source_path})")
|
|
if earliest_real_import is None:
|
|
earliest_real_import = date_iso
|
|
elif date_iso:
|
|
_log("INFO", f"⚠️ Skipping non-download import '{ev}' from {source_path}")
|
|
|
|
elif ev == "grabbed" and date_iso and first_grab is None:
|
|
first_grab = date_iso
|
|
_log("INFO", f"Found grab event at {date_iso}")
|
|
|
|
if earliest_real_import:
|
|
_log("INFO", f"✅ USING EARLIEST REAL IMPORT DATE: {earliest_real_import}")
|
|
return earliest_real_import
|
|
|
|
if first_grab:
|
|
_log("INFO", f"⚠️ Using grab date {first_grab} as fallback for movie_id {movie_id}")
|
|
return first_grab
|
|
|
|
_log("ERROR", f"❌ No real import/grab events found for movie_id {movie_id}")
|
|
return None
|
|
|
|
# ---------------------------
|
|
# TMDB / OMDb / Jellyseerr helpers
|
|
# ---------------------------
|
|
|
|
def _tmdb_id_from_imdb(imdb_id: str) -> Optional[int]:
|
|
if not config.tmdb_api_key:
|
|
return None
|
|
try:
|
|
url_find = f"https://api.themoviedb.org/3/find/{quote(imdb_id)}?{urlencode({'api_key': config.tmdb_api_key, 'external_source':'imdb_id'})}"
|
|
j = _get_json(url_find, timeout=20)
|
|
res = (j or {}).get("movie_results") or []
|
|
if res:
|
|
return res[0].get("id")
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def tmdb_digital_date_from_imdb(imdb_id: str) -> Optional[str]:
|
|
if not config.tmdb_api_key:
|
|
return None
|
|
try:
|
|
tmdb_id = _tmdb_id_from_imdb(imdb_id)
|
|
if not tmdb_id:
|
|
return None
|
|
url_rel = f"https://api.themoviedb.org/3/movie/{tmdb_id}/release_dates?{urlencode({'api_key': config.tmdb_api_key})}"
|
|
k = _get_json(url_rel, timeout=20)
|
|
if not k:
|
|
return None
|
|
for entry in (k.get("results") or []):
|
|
if (entry.get("iso_3166_1") or "").upper() != config.tmdb_country:
|
|
continue
|
|
for rd in (entry.get("release_dates") or []):
|
|
if rd.get("type") == 4 and rd.get("release_date"): # 4 = Digital
|
|
try:
|
|
dt = datetime.fromisoformat(rd["release_date"].replace("Z", "+00:00"))
|
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
|
except Exception:
|
|
continue
|
|
return None
|
|
except Exception as e:
|
|
_log("WARNING", f"TMDB lookup failed for {imdb_id}: {e}")
|
|
return None
|
|
|
|
def jellyseerr_digital_date_from_tmdb(tmdb_id: int) -> Optional[str]:
|
|
base = config.jelly_url
|
|
api = config.jelly_api
|
|
if not (base and api and tmdb_id):
|
|
return None
|
|
try:
|
|
url = base.rstrip("/") + f"/api/v1/movie/{tmdb_id}"
|
|
j = _get_json(url, timeout=20, headers={"X-Api-Key": api, "Accept": "application/json"})
|
|
if not j:
|
|
return None
|
|
candidates = []
|
|
for k in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"):
|
|
v = j.get(k)
|
|
if v:
|
|
iso = _parse_date_to_iso(v)
|
|
if iso:
|
|
candidates.append(iso)
|
|
for arr_key in ("releaseDates", "releases", "dates"):
|
|
arr_data = j.get(arr_key)
|
|
if not isinstance(arr_data, list):
|
|
continue
|
|
for rd in arr_data:
|
|
if not isinstance(rd, dict):
|
|
continue
|
|
name = (rd.get("type") or rd.get("label") or "").lower()
|
|
v = rd.get("date") or rd.get("releaseDate")
|
|
if v and ("digital" in name or "vod" in name or "stream" in name):
|
|
iso = _parse_date_to_iso(v)
|
|
if iso:
|
|
candidates.append(iso)
|
|
if not candidates:
|
|
return None
|
|
return sorted(
|
|
candidates,
|
|
key=lambda s: datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
)[0]
|
|
except Exception as e:
|
|
_log("WARNING", f"Jellyseerr lookup failed for tmdb:{tmdb_id}: {e}")
|
|
return None
|
|
|
|
def omdb_dvd_date(imdb_id: str) -> Optional[str]:
|
|
if not config.omdb_api_key:
|
|
return None
|
|
try:
|
|
url = f"http://www.omdbapi.com/?{urlencode({'i': imdb_id, 'apikey': config.omdb_api_key})}"
|
|
j = _get_json(url, timeout=15)
|
|
if not j or _norm(j.get("Response")) != "true":
|
|
return None
|
|
dvd = j.get("DVD") or j.get("Released")
|
|
if not dvd:
|
|
return None
|
|
for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"):
|
|
try:
|
|
dt = datetime.strptime(dvd, fmt).replace(tzinfo=timezone.utc)
|
|
return dt.isoformat(timespec="seconds")
|
|
except Exception:
|
|
continue
|
|
return None
|
|
except Exception as e:
|
|
_log("WARNING", f"OMDb lookup failed for {imdb_id}: {e}")
|
|
return None
|
|
|
|
# ---------------------------
|
|
# XML helpers (pretty + upsert)
|
|
# ---------------------------
|
|
|
|
def _ensure_child_text(parent: ET.Element, tag: str, text: str, overwrite: bool = True) -> ET.Element:
|
|
child = parent.find(tag)
|
|
if child is None:
|
|
child = ET.SubElement(parent, tag)
|
|
child.text = text
|
|
else:
|
|
if overwrite or (child.text is None or str(child.text).strip() == ""):
|
|
child.text = text
|
|
return child
|
|
|
|
def _ensure_uniqueid_imdb(root: ET.Element, imdb_id: str):
|
|
imdb_elem = None
|
|
for uid in root.findall("uniqueid"):
|
|
if (uid.attrib.get("type") or "").lower() == "imdb":
|
|
imdb_elem = uid
|
|
break
|
|
if imdb_elem is None:
|
|
imdb_elem = ET.SubElement(root, "uniqueid", {"type": "imdb", "default": "true"})
|
|
else:
|
|
if "default" not in imdb_elem.attrib:
|
|
imdb_elem.set("default", "true")
|
|
imdb_elem.text = imdb_id
|
|
|
|
def _indent(elem: ET.Element, level: int = 0, space: str = " "):
|
|
i = "\n" + level * space
|
|
if len(elem):
|
|
if not elem.text or not elem.text.strip():
|
|
elem.text = i + space
|
|
for idx, e in enumerate(list(elem)):
|
|
_indent(e, level + 1, space)
|
|
if not e.tail or not e.tail.strip():
|
|
e.tail = i + (space if idx < len(elem) - 1 else "")
|
|
if not elem.tail or not elem.tail.strip():
|
|
elem.tail = "\n" + (level - 1) * space if level else "\n"
|
|
else:
|
|
if not elem.text or not elem.text.strip():
|
|
elem.text = ""
|
|
if not elem.tail or not elem.tail.strip():
|
|
elem.tail = "\n" + (level - 1) * space if level else "\n"
|
|
|
|
def _strip_existing_footer_comments(root: ET.Element):
|
|
for node in list(root):
|
|
if isinstance(node.tag, str):
|
|
continue
|
|
txt = (node.text or "").strip().lower()
|
|
if "source:" in txt or "managed by" in txt:
|
|
root.remove(node)
|
|
|
|
def upsert_movie_nfo(
|
|
movie_dir: Path,
|
|
imdb_id: str,
|
|
released: Optional[str],
|
|
dateadded: Optional[str],
|
|
source_detail: str,
|
|
lock_metadata: bool = True,
|
|
media_label: str = "Movie",
|
|
brand: str = "NFOGuard",
|
|
):
|
|
canonical_path = movie_dir / "movie.nfo"
|
|
alt_path = movie_dir / f"{movie_dir.name}.nfo"
|
|
|
|
root = None
|
|
for p in (canonical_path, alt_path):
|
|
if p.exists():
|
|
try:
|
|
t = ET.parse(str(p))
|
|
r = t.getroot()
|
|
if r is not None and r.tag.lower() == "movie":
|
|
root = r
|
|
break
|
|
except Exception:
|
|
pass
|
|
if root is None:
|
|
root = ET.Element("movie")
|
|
|
|
if imdb_id and not imdb_id.lower().startswith("tt"):
|
|
imdb_id = f"tt{imdb_id}"
|
|
|
|
if imdb_id:
|
|
_ensure_uniqueid_imdb(root, imdb_id)
|
|
|
|
if released:
|
|
ymd = released.split("T")[0]
|
|
_ensure_child_text(root, "premiered", ymd, overwrite=False)
|
|
_ensure_child_text(root, "year", ymd.split("-")[0], overwrite=False)
|
|
|
|
if dateadded:
|
|
if dateadded == "MANUAL_REVIEW_NEEDED":
|
|
_ensure_child_text(root, "dateadded", "MANUAL_REVIEW_NEEDED", overwrite=True)
|
|
_log("WARNING", f"Set MANUAL_REVIEW_NEEDED marker in NFO: {movie_dir}/movie.nfo")
|
|
else:
|
|
_ensure_child_text(root, "dateadded", dateadded, overwrite=True)
|
|
|
|
if lock_metadata:
|
|
_ensure_child_text(root, "lockdata", "true", overwrite=True)
|
|
|
|
_strip_existing_footer_comments(root)
|
|
root.append(ET.Comment(f" source: {media_label}; {source_detail} "))
|
|
root.append(ET.Comment(f" managed by {brand} "))
|
|
|
|
_indent(root)
|
|
|
|
for target in [canonical_path] + ([alt_path] if alt_path.exists() else []):
|
|
try:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
ET.ElementTree(root).write(str(target), encoding="utf-8", xml_declaration=True)
|
|
_log("DEBUG", f"Upserted NFO: {target}")
|
|
except Exception as e:
|
|
_log("ERROR", f"Failed writing {target}: {e}")
|
|
|
|
# ---------------------------
|
|
# Date selection
|
|
# ---------------------------
|
|
|
|
def _parse_date_to_iso(d: str) -> Optional[str]:
|
|
if not d:
|
|
return None
|
|
try:
|
|
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d):
|
|
dt = datetime.fromisoformat(d).replace(tzinfo=timezone.utc)
|
|
else:
|
|
dt = datetime.fromisoformat(d.replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
|
|
# Reject dates that are too recent (prevent bogus timestamps)
|
|
now = datetime.now(timezone.utc)
|
|
if (now - dt).days < 1:
|
|
_log("WARNING", f"Rejecting recent timestamp (less than 1 day old): {d} -> {dt.isoformat()}")
|
|
return None
|
|
|
|
return dt.isoformat(timespec="seconds")
|
|
except Exception:
|
|
return None
|
|
|
|
def _radarr_movie_by_imdb(imdb_id: str) -> Optional[Dict[str, Any]]:
|
|
if not (config.radarr_url and config.radarr_api_key):
|
|
return None
|
|
rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries)
|
|
return rc.movie_by_imdb(imdb_id)
|
|
|
|
def _radarr_earliest_import_history_iso(movie_id: int) -> Optional[str]:
|
|
rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries)
|
|
return rc.earliest_import_event(movie_id)
|
|
|
|
def _radarr_earliest_file_dateadded_iso(movie_id: int) -> Optional[str]:
|
|
rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries)
|
|
return rc.earliest_dateadded(movie_id)
|
|
|
|
def _strategy_movie_date_from_movie_obj(movie_obj: Dict[str, Any], which: str) -> Optional[str]:
|
|
key_map = {
|
|
"digital": ["digitalRelease", "digitalReleaseDate"],
|
|
"physical": ["physicalRelease", "physicalReleaseDate"],
|
|
"cinema": ["inCinemas", "inCinema", "theatricalRelease"],
|
|
}
|
|
for k in key_map.get(which, []):
|
|
val = movie_obj.get(k)
|
|
iso = _parse_date_to_iso(val) if val else None
|
|
if iso:
|
|
return iso
|
|
return None
|
|
|
|
def _collect_digital_candidates(imdb_id: str, movie_obj: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]:
|
|
cands: List[Tuple[str, str]] = []
|
|
_log("DEBUG", f"Collecting digital dates for {imdb_id}")
|
|
|
|
if movie_obj:
|
|
iso = _strategy_movie_date_from_movie_obj(movie_obj, "digital")
|
|
if iso:
|
|
cands.append((iso, "radarr:digitalRelease"))
|
|
_log("DEBUG", f"Found Radarr digital date: {iso}")
|
|
|
|
iso = tmdb_digital_date_from_imdb(imdb_id)
|
|
if iso:
|
|
cands.append((iso, "tmdb:digital"))
|
|
_log("DEBUG", f"Found TMDB digital date: {iso}")
|
|
|
|
tmdb_id = _tmdb_id_from_imdb(imdb_id)
|
|
if tmdb_id:
|
|
iso = jellyseerr_digital_date_from_tmdb(tmdb_id)
|
|
if iso:
|
|
cands.append((iso, "jellyseerr:digital"))
|
|
_log("DEBUG", f"Found Jellyseerr digital date: {iso}")
|
|
|
|
iso = omdb_dvd_date(imdb_id)
|
|
if iso:
|
|
cands.append((iso, "omdb:dvd"))
|
|
_log("DEBUG", f"Found OMDb DVD date: {iso}")
|
|
|
|
_log("INFO", f"Found {len(cands)} digital date candidates: {[f'{src}={date}' for date, src in cands]}")
|
|
return cands
|
|
|
|
def _pick_earliest(cands: List[Tuple[str, str]]) -> Optional[Tuple[str, str]]:
|
|
if not cands:
|
|
return None
|
|
try:
|
|
return sorted(
|
|
cands,
|
|
key=lambda t: datetime.fromisoformat(t[0].replace("Z", "+00:00"))
|
|
)[0]
|
|
except Exception:
|
|
return cands[0]
|
|
|
|
def _pick_digital_closest_to_cinema(cands: List[Tuple[str, str]], cinema_date: Optional[str]) -> Optional[Tuple[str, str]]:
|
|
"""Pick digital release date closest to cinema date, with TMDB preferred over Radarr as tiebreaker"""
|
|
if not cands:
|
|
return None
|
|
if not cinema_date:
|
|
return _pick_earliest(cands) # Fall back to earliest if no cinema date
|
|
|
|
try:
|
|
cinema_dt = datetime.fromisoformat(cinema_date.replace("Z", "+00:00"))
|
|
|
|
# Source preference: TMDB > Jellyseerr > OMDb > Radarr
|
|
source_priority = {
|
|
"tmdb:digital": 0,
|
|
"jellyseerr:digital": 1,
|
|
"omdb:dvd": 2,
|
|
"radarr:digitalRelease": 3
|
|
}
|
|
|
|
# Sort candidates by cinema proximity first, then by source preference
|
|
def sort_key(candidate):
|
|
try:
|
|
cand_dt = datetime.fromisoformat(candidate[0].replace("Z", "+00:00"))
|
|
days_diff = abs((cand_dt - cinema_dt).days)
|
|
|
|
# Primary sort: distance from cinema (within 5 years preferred)
|
|
if days_diff <= 1825: # 5 years * 365 days
|
|
distance_score = days_diff
|
|
else:
|
|
# Heavily penalize dates far from cinema release
|
|
distance_score = days_diff + 10000
|
|
|
|
# Secondary sort: source preference (lower number = higher preference)
|
|
source_score = source_priority.get(candidate[1], 99)
|
|
|
|
# Return tuple for sorting: (distance, source_priority)
|
|
return (distance_score, source_score)
|
|
|
|
except Exception:
|
|
return (999999, 99) # Invalid dates/sources go to the end
|
|
|
|
sorted_cands = sorted(cands, key=sort_key)
|
|
selected = sorted_cands[0]
|
|
|
|
# Log the decision for debugging
|
|
try:
|
|
selected_dt = datetime.fromisoformat(selected[0].replace("Z", "+00:00"))
|
|
days_diff = (selected_dt - cinema_dt).days
|
|
_log("DEBUG", f"Selected digital date {selected[0]} ({selected[1]}) - {days_diff} days from cinema date {cinema_date}")
|
|
|
|
# Show other candidates if multiple exist
|
|
if len(cands) > 1:
|
|
other_cands = [f"{c[1]}={c[0]}" for c in sorted_cands[1:3]] # Show top 2 alternatives
|
|
_log("DEBUG", f"Other candidates considered: {other_cands}")
|
|
except Exception:
|
|
pass
|
|
|
|
return selected
|
|
|
|
except Exception:
|
|
_log("WARNING", "Failed to parse cinema date for digital selection, using earliest")
|
|
return _pick_earliest(cands)
|
|
|
|
def _decide_movie_date(imdb_id: str, movie_dir: Path, poll_external: bool) -> Tuple[Optional[str], str, Optional[str]]:
|
|
"""
|
|
Decide <dateadded> and supporting 'released' (premiered/year) for NFO.
|
|
|
|
Returns: (dateadded_iso, source_detail, released_iso)
|
|
"""
|
|
# Initialize fresh variables for each movie
|
|
released_iso: Optional[str] = None
|
|
movie_obj: Optional[Dict[str, Any]] = None
|
|
movie_id: Optional[int] = None
|
|
|
|
_log("DEBUG", f"Starting date selection for {imdb_id} - fresh state initialized")
|
|
|
|
def from_file_mtime() -> Tuple[Optional[str], str]:
|
|
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
|
newest = None
|
|
for f in movie_dir.iterdir():
|
|
if f.is_file() and f.suffix.lower() in video_exts:
|
|
try:
|
|
mt = f.stat().st_mtime
|
|
if newest is None or mt > newest:
|
|
newest = mt
|
|
except Exception:
|
|
continue
|
|
if newest:
|
|
try:
|
|
iso = datetime.fromtimestamp(newest, tz=timezone.utc).isoformat(timespec="seconds")
|
|
return iso, "file:mtime"
|
|
except Exception:
|
|
pass
|
|
return None, "file:mtime"
|
|
|
|
def ensure_movie():
|
|
nonlocal movie_obj, movie_id, released_iso
|
|
if movie_obj is None and poll_external and config.radarr_url and config.radarr_api_key:
|
|
_log("DEBUG", f"Looking up Radarr movie for IMDb ID: {imdb_id}")
|
|
movie_obj = _radarr_movie_by_imdb(imdb_id)
|
|
if movie_obj:
|
|
movie_id = movie_obj.get("id")
|
|
movie_title = movie_obj.get("title", "Unknown")
|
|
movie_imdb = movie_obj.get("imdbId", "Unknown")
|
|
|
|
# CRITICAL: Verify returned movie matches requested IMDb ID
|
|
if movie_imdb.lower() != imdb_id.lower():
|
|
_log("ERROR", f"Radarr API returned WRONG movie! Requested: {imdb_id}, Got: {movie_imdb} ({movie_title}). Ignoring this result.")
|
|
movie_obj = None
|
|
movie_id = None
|
|
_log("WARNING", f"Movie {imdb_id} not properly found in Radarr - will use digital/cinema dates instead")
|
|
else:
|
|
_log("INFO", f"Found Radarr movie: {movie_title} (ID: {movie_id}, IMDb: {movie_imdb}) for requested {imdb_id}")
|
|
released_iso = _parse_date_to_iso(movie_obj.get("inCinemas") or movie_obj.get("theatricalRelease"))
|
|
else:
|
|
movie_id = None # Explicitly set to None when movie not found
|
|
movie_obj = None
|
|
_log("WARNING", f"No Radarr movie found for IMDb ID: {imdb_id} - will skip import history")
|
|
|
|
def from_import_history() -> Tuple[Optional[str], str]:
|
|
ensure_movie()
|
|
if not poll_external:
|
|
_log("DEBUG", f"Skipping import history: poll_external={poll_external}")
|
|
return None, "radarr:history.skipped"
|
|
|
|
if not movie_id:
|
|
_log("DEBUG", f"Skipping import history: movie not found in Radarr for {imdb_id}")
|
|
return None, "radarr:movie.not_found"
|
|
|
|
# Try import history events first (most reliable)
|
|
iso = _radarr_earliest_import_history_iso(movie_id)
|
|
if iso:
|
|
_log("INFO", f"Using Radarr import history date: {iso} for movie_id {movie_id} (IMDb: {imdb_id})")
|
|
return iso, "radarr:history.import"
|
|
|
|
# If no real import events found, return None to continue to digital/cinema dates
|
|
# instead of falling back to arbitrary moviefile.dateAdded
|
|
_log("DEBUG", f"No real import events found for movie_id {movie_id}, will try digital/cinema dates")
|
|
return None, "radarr:history.none"
|
|
|
|
def from_radarr_field(which: str) -> Tuple[Optional[str], str]:
|
|
ensure_movie()
|
|
label_map = {"digital": "radarr:digitalRelease", "physical": "radarr:physicalRelease", "cinema": "radarr:inCinemas"}
|
|
if poll_external and movie_obj:
|
|
iso = _strategy_movie_date_from_movie_obj(movie_obj, which)
|
|
return iso, label_map.get(which, "radarr:field")
|
|
return None, label_map.get(which, "radarr:field")
|
|
|
|
def from_external_digital_bundle() -> Tuple[Optional[str], str]:
|
|
ensure_movie()
|
|
cands = _collect_digital_candidates(imdb_id, movie_obj)
|
|
# Use cinema date to pick the most appropriate digital release
|
|
cinema_date = None
|
|
if movie_obj:
|
|
cinema_date = movie_obj.get("inCinemas") or movie_obj.get("theatricalRelease")
|
|
picked = _pick_digital_closest_to_cinema(cands, cinema_date)
|
|
return picked if picked else (None, "digital:none")
|
|
|
|
def from_moviefile_dateadded() -> Tuple[Optional[str], str]:
|
|
"""Last resort fallback for existing files that were just renamed/scanned"""
|
|
ensure_movie()
|
|
if not movie_id:
|
|
return None, "radarr:moviefile.not_found"
|
|
iso = _radarr_earliest_file_dateadded_iso(movie_id)
|
|
if iso:
|
|
_log("INFO", f"Using Radarr moviefile dateAdded as fallback: {iso} for movie_id {movie_id}")
|
|
return iso, "radarr:moviefile.dateAdded"
|
|
return None, "radarr:moviefile.none"
|
|
|
|
def maybe_sanitize_digital(digi_iso: Optional[str]) -> Tuple[Optional[str], str]:
|
|
if not digi_iso or not config.movie_digital_sanity_check:
|
|
return digi_iso, "radarr:digitalRelease"
|
|
ref_dates: List[datetime] = []
|
|
if config.movie_digital_sanity_ref in ("cinema", "min"):
|
|
cine_iso, _ = from_radarr_field("cinema")
|
|
if cine_iso:
|
|
try:
|
|
ref_dates.append(datetime.fromisoformat(cine_iso.replace("Z", "+00:00")))
|
|
except Exception:
|
|
pass
|
|
if config.movie_digital_sanity_ref in ("import", "min"):
|
|
imp_iso, _ = from_import_history()
|
|
if imp_iso:
|
|
try:
|
|
ref_dates.append(datetime.fromisoformat(imp_iso.replace("Z", "+00:00")))
|
|
except Exception:
|
|
pass
|
|
if not ref_dates:
|
|
return digi_iso, "radarr:digitalRelease"
|
|
try:
|
|
d_digi = datetime.fromisoformat(digi_iso.replace("Z", "+00:00"))
|
|
ref_dt = min(ref_dates)
|
|
lag = (d_digi - ref_dt).days
|
|
if lag > config.movie_digital_max_lag_days:
|
|
if config.movie_digital_invalid_fallback == "import":
|
|
imp_iso2, imp_src2 = from_import_history()
|
|
if imp_iso2:
|
|
return imp_iso2, "radarr:history.import"
|
|
cine_iso2, _ = from_radarr_field("cinema")
|
|
if cine_iso2:
|
|
return cine_iso2, "radarr:inCinemas"
|
|
except Exception:
|
|
pass
|
|
return digi_iso, "radarr:digitalRelease"
|
|
|
|
# Priority flows
|
|
_log("INFO", f"Date selection for {imdb_id}: priority={config.movie_priority}, poll_external={poll_external}")
|
|
|
|
if config.movie_priority == "import_then_digital":
|
|
_log("DEBUG", "Trying import history first (import_then_digital mode)")
|
|
imp_iso, imp_src = from_import_history()
|
|
if imp_iso:
|
|
_log("INFO", f"Selected import date: {imp_iso} ({imp_src})")
|
|
return imp_iso, imp_src, released_iso
|
|
|
|
_log("DEBUG", "No import date found, trying digital release dates")
|
|
digi_iso, digi_src = from_external_digital_bundle()
|
|
digi_iso, digi_src = maybe_sanitize_digital(digi_iso)
|
|
if digi_iso:
|
|
_log("INFO", f"Selected digital date: {digi_iso} ({digi_src})")
|
|
return digi_iso, digi_src, released_iso
|
|
|
|
_log("DEBUG", "No digital date found, trying cinema release")
|
|
cine_iso, cine_src = from_radarr_field("cinema")
|
|
if cine_iso:
|
|
_log("INFO", f"Selected cinema date: {cine_iso} ({cine_src})")
|
|
return cine_iso, cine_src, released_iso or cine_iso
|
|
|
|
_log("DEBUG", "No external dates found, trying moviefile dateAdded as fallback")
|
|
moviefile_iso, moviefile_src = from_moviefile_dateadded()
|
|
if moviefile_iso:
|
|
_log("WARNING", f"Using moviefile dateAdded for existing/renamed file: {moviefile_iso} ({moviefile_src})")
|
|
return moviefile_iso, moviefile_src, released_iso
|
|
|
|
_log("DEBUG", "No moviefile date found, falling back to file mtime")
|
|
mtime_iso, mtime_src = from_file_mtime()
|
|
if mtime_iso:
|
|
_log("WARNING", f"Using file mtime as last resort: {mtime_iso} ({mtime_src})")
|
|
return mtime_iso, mtime_src, released_iso
|
|
|
|
# No valid date found - create manual review marker
|
|
_log("ERROR", f"No valid dateadded found for {imdb_id} - marking for MANUAL_REVIEW_NEEDED")
|
|
return "MANUAL_REVIEW_NEEDED", "manual_review_required", released_iso
|
|
|
|
# digital_then_import
|
|
_log("DEBUG", "Trying digital release dates first (digital_then_import mode)")
|
|
digi_iso, digi_src = from_external_digital_bundle()
|
|
digi_iso, digi_src = maybe_sanitize_digital(digi_iso)
|
|
if digi_iso:
|
|
_log("INFO", f"Selected digital date: {digi_iso} ({digi_src})")
|
|
return digi_iso, digi_src, released_iso
|
|
|
|
_log("DEBUG", "No digital date found, trying import history")
|
|
imp_iso, imp_src = from_import_history()
|
|
if imp_iso:
|
|
_log("INFO", f"Selected import date: {imp_iso} ({imp_src})")
|
|
return imp_iso, imp_src, released_iso
|
|
|
|
_log("DEBUG", "No import date found, trying cinema release")
|
|
cine_iso, cine_src = from_radarr_field("cinema")
|
|
if cine_iso:
|
|
_log("INFO", f"Selected cinema date: {cine_iso} ({cine_src})")
|
|
return cine_iso, cine_src, released_iso or cine_iso
|
|
|
|
_log("DEBUG", "No external dates found, falling back to file mtime")
|
|
mtime_iso, mtime_src = from_file_mtime()
|
|
if mtime_iso:
|
|
_log("WARNING", f"Using file mtime as last resort: {mtime_iso} ({mtime_src})")
|
|
return mtime_iso, mtime_src, released_iso
|
|
|
|
# No valid date found - create manual review marker
|
|
_log("ERROR", f"No valid dateadded found for {imdb_id} - marking for MANUAL_REVIEW_NEEDED")
|
|
return "MANUAL_REVIEW_NEEDED", "manual_review_required", released_iso
|
|
|
|
# ---------------------------
|
|
# Movie apply
|
|
# ---------------------------
|
|
|
|
def parse_imdb_from_movie_path(p: Path) -> Optional[str]:
|
|
m = re.search(r'\[imdb-(tt\d+)\]', p.name, re.IGNORECASE)
|
|
imdb_id = m.group(1).lower() if m else None
|
|
_log("DEBUG", f"Parsed IMDb ID from path '{p.name}': {imdb_id}")
|
|
return imdb_id
|
|
|
|
def is_movie_dir(p: Path) -> bool:
|
|
return p.is_dir() and parse_imdb_from_movie_path(p) is not None
|
|
|
|
def set_movie_file_mtime(movie_dir: Path, dateadded_iso: str):
|
|
if not dateadded_iso:
|
|
return
|
|
try:
|
|
ts = datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp()
|
|
|
|
# Update ALL files in the directory to the correct timestamp
|
|
files_updated = 0
|
|
for f in movie_dir.iterdir():
|
|
if f.is_file():
|
|
try:
|
|
os.utime(f, (ts, ts), follow_symlinks=False)
|
|
files_updated += 1
|
|
_log("DEBUG", f"Updated file mtime: {f.name} -> {dateadded_iso}")
|
|
except Exception as e:
|
|
_log("WARNING", f"Failed to update mtime on {f.name}: {e}")
|
|
|
|
# Update directory timestamp
|
|
try:
|
|
os.utime(movie_dir, (ts, ts), follow_symlinks=False)
|
|
_log("INFO", f"Updated {files_updated} files and directory mtime: {movie_dir.name} -> {dateadded_iso}")
|
|
except Exception as e:
|
|
_log("WARNING", f"Failed to update mtime on directory {movie_dir}: {e}")
|
|
except Exception as e:
|
|
_log("WARNING", f"Bad timestamp for movie mtimes: {dateadded_iso}: {e}")
|
|
|
|
def apply_movie(movie_dir: Path, conn: sqlite3.Connection):
|
|
imdb_id = parse_imdb_from_movie_path(movie_dir)
|
|
if not imdb_id:
|
|
_log("ERROR", f"Movie path does not contain an IMDb id [imdb-tt...]: {movie_dir}")
|
|
return
|
|
|
|
ensure_movie_tables(conn)
|
|
|
|
conn.execute(
|
|
"INSERT INTO movies(imdb_id, movie_path, last_applied) VALUES(?,?,?) "
|
|
"ON CONFLICT(imdb_id) DO UPDATE SET movie_path=excluded.movie_path, last_applied=excluded.last_applied",
|
|
(imdb_id, str(movie_dir), _iso_now()),
|
|
)
|
|
|
|
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
|
has_video_files = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_dir.iterdir())
|
|
if not has_video_files:
|
|
_log("WARNING", f"No video files found in movie directory: {movie_dir}")
|
|
conn.execute(
|
|
"INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) "
|
|
"ON CONFLICT(imdb_id) DO UPDATE SET has_video_file=excluded.has_video_file",
|
|
(imdb_id, None, None, None, 0),
|
|
)
|
|
conn.commit()
|
|
return
|
|
|
|
row = conn.execute(
|
|
"SELECT released, dateadded, source FROM movie_dates WHERE imdb_id=?", (imdb_id,)
|
|
).fetchone()
|
|
existing_released = row[0] if row else None
|
|
existing_dateadded = row[1] if row else None
|
|
existing_source = row[2] if row else None
|
|
|
|
# poll policy
|
|
if config.movie_poll_mode == "never":
|
|
poll_external = False
|
|
elif config.movie_poll_mode == "if_missing":
|
|
poll_external = not existing_dateadded or (existing_source or "") == "file:mtime"
|
|
else:
|
|
poll_external = True
|
|
|
|
preserve = False
|
|
if config.movie_date_update_mode == "backfill_only":
|
|
if existing_dateadded:
|
|
if (config.movie_preserve_radarr_import and (existing_source or "").startswith("radarr:moviefile")) \
|
|
or (config.movie_preserve_existing and (existing_source or "") != "file:mtime"):
|
|
preserve = True
|
|
|
|
if preserve:
|
|
dateadded_iso = existing_dateadded
|
|
src_detail = existing_source or "unknown"
|
|
if not existing_released and poll_external and (config.radarr_url and config.radarr_api_key):
|
|
movie_obj = _radarr_movie_by_imdb(imdb_id)
|
|
if movie_obj:
|
|
existing_released = _parse_date_to_iso(movie_obj.get("inCinemas") or movie_obj.get("theatricalRelease"))
|
|
chosen_released = existing_released
|
|
_log("INFO", f"Preserving existing movie date for {movie_dir.name} ({dateadded_iso}; {src_detail})")
|
|
else:
|
|
dateadded_iso, src_detail, decided_released = _decide_movie_date(imdb_id, movie_dir, poll_external=poll_external)
|
|
chosen_released = existing_released or decided_released
|
|
conn.execute(
|
|
"INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) "
|
|
"ON CONFLICT(imdb_id) DO UPDATE SET released=excluded.released, dateadded=excluded.dateadded, "
|
|
"source=excluded.source, has_video_file=excluded.has_video_file",
|
|
(imdb_id, chosen_released, dateadded_iso, src_detail, 1),
|
|
)
|
|
|
|
if config.manage_nfo:
|
|
upsert_movie_nfo(
|
|
movie_dir,
|
|
imdb_id,
|
|
chosen_released,
|
|
dateadded_iso,
|
|
source_detail=src_detail or "unknown",
|
|
lock_metadata=config.lock_metadata,
|
|
media_label="Movie",
|
|
brand=config.manager_brand,
|
|
)
|
|
|
|
if config.fix_dir_mtimes and dateadded_iso and dateadded_iso != "MANUAL_REVIEW_NEEDED":
|
|
set_movie_file_mtime(movie_dir, dateadded_iso)
|
|
|
|
conn.commit()
|
|
_log("INFO", f"Processed movie: {movie_dir.name} (source={src_detail or 'unknown'}; preserved={preserve})")
|
|
|
|
# Add detailed logging for debugging with searchable format
|
|
_log("INFO", f"MOVIE_SUMMARY: imdb={imdb_id} title='{movie_dir.name}' dateadded={dateadded_iso} source={src_detail} preserved={preserve}")
|
|
|
|
# ---------------------------
|
|
# Batching
|
|
# ---------------------------
|
|
|
|
class WebhookBatcher:
|
|
def __init__(self):
|
|
self.pending_items: Dict[str, Dict] = {}
|
|
self.timers: Dict[str, threading.Timer] = {}
|
|
self.processing: Set[str] = set()
|
|
self.lock = threading.Lock()
|
|
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent_series, thread_name_prefix="media-processor")
|
|
|
|
def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
|
|
with self.lock:
|
|
if key in self.timers:
|
|
self.timers[key].cancel()
|
|
_log("DEBUG", f"Cancelled existing timer for {key}")
|
|
|
|
webhook_data['media_type'] = media_type
|
|
self.pending_items[key] = webhook_data
|
|
_log("INFO", f"Batched {media_type} webhook for {key} (delay: {config.batch_delay}s)")
|
|
|
|
timer = threading.Timer(config.batch_delay, self._process_item, args=[key])
|
|
self.timers[key] = timer
|
|
timer.start()
|
|
|
|
def _process_item(self, key: str):
|
|
with self.lock:
|
|
if key in self.processing or key not in self.pending_items:
|
|
return
|
|
self.processing.add(key)
|
|
webhook_data = self.pending_items.pop(key)
|
|
self.timers.pop(key, None)
|
|
|
|
try:
|
|
media_type = webhook_data.get('media_type', 'unknown')
|
|
_log("INFO", f"Processing batched {media_type}: {key}")
|
|
self.executor.submit(self._process_item_sync, key, webhook_data)
|
|
except Exception as e:
|
|
_log("ERROR", f"Error submitting {media_type} processing for {key}: {e}")
|
|
with self.lock:
|
|
self.processing.discard(key)
|
|
|
|
def _process_item_sync(self, key: str, webhook_data: Dict):
|
|
try:
|
|
media_type = webhook_data.get('media_type', 'unknown')
|
|
if media_type == 'tv':
|
|
self._process_tv_sync(key, webhook_data)
|
|
elif media_type == 'movie':
|
|
self._process_movie_sync(key, webhook_data)
|
|
else:
|
|
_log("ERROR", f"Unknown media type: {media_type}")
|
|
finally:
|
|
with self.lock:
|
|
self.processing.discard(key)
|
|
|
|
def _process_tv_sync(self, imdb_id: str, webhook_data: Dict):
|
|
try:
|
|
series_info = webhook_data.get('series', {})
|
|
series_title = series_info.get("title", "Unknown")
|
|
series_path_str = webhook_data.get('series_path')
|
|
|
|
if not series_path_str:
|
|
_log("ERROR", f"No series path found for {imdb_id}")
|
|
return
|
|
|
|
series_path = Path(series_path_str)
|
|
if not series_path.exists():
|
|
_log("ERROR", f"Series path does not exist: {series_path}")
|
|
return
|
|
|
|
conn = None
|
|
try:
|
|
conn = db_connect(db_path)
|
|
conn.execute("PRAGMA busy_timeout = 30000")
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
|
|
class MockArgs:
|
|
def __init__(self):
|
|
self.manage_nfo = config.manage_nfo
|
|
self.fix_dir_mtimes = config.fix_dir_mtimes
|
|
self.lock_metadata = config.lock_metadata
|
|
self.use_cache = True
|
|
self.force_refresh = False
|
|
self.skip_if_current = False
|
|
self.debug = config.debug
|
|
self.update_video_mtimes = True
|
|
|
|
_log("INFO", f"Processing TV series: {series_title} at {series_path}")
|
|
apply_series(series_path, MockArgs(), conn)
|
|
_log("INFO", f"Successfully processed batched TV series: {series_title}")
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Error processing TV series {series_title}: {e}")
|
|
finally:
|
|
if conn:
|
|
try:
|
|
conn.close()
|
|
except:
|
|
pass
|
|
except Exception as e:
|
|
_log("ERROR", f"Error in TV processing: {e}")
|
|
|
|
def _process_movie_sync(self, imdb_id: str, webhook_data: Dict):
|
|
try:
|
|
movie_info = webhook_data.get('movie', {})
|
|
movie_title = movie_info.get("title", "Unknown")
|
|
movie_path_str = webhook_data.get('movie_path')
|
|
|
|
if not movie_path_str:
|
|
_log("ERROR", f"No movie path found for {imdb_id}")
|
|
return
|
|
|
|
movie_path = Path(movie_path_str)
|
|
if not movie_path.exists():
|
|
_log("ERROR", f"Movie path does not exist: {movie_path}")
|
|
return
|
|
|
|
conn = None
|
|
try:
|
|
conn = db_connect(db_path)
|
|
conn.execute("PRAGMA busy_timeout = 30000")
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
|
|
_log("INFO", f"Processing movie: {movie_title} at {movie_path}")
|
|
apply_movie(movie_path, conn)
|
|
_log("INFO", f"Successfully processed batched movie: {movie_title}")
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Error processing movie {movie_title}: {e}")
|
|
finally:
|
|
if conn:
|
|
try:
|
|
conn.close()
|
|
except:
|
|
pass
|
|
except Exception as e:
|
|
_log("ERROR", f"Error in movie processing: {e}")
|
|
|
|
def get_status(self) -> Dict:
|
|
with self.lock:
|
|
return {
|
|
"pending_items": list(self.pending_items.keys()),
|
|
"processing_items": list(self.processing),
|
|
"pending_count": len(self.pending_items),
|
|
"processing_count": len(self.processing),
|
|
"executor_active_threads": getattr(self.executor, '_threads', 0)
|
|
}
|
|
|
|
batcher = WebhookBatcher()
|
|
|
|
# ---------------------------
|
|
# Path helpers
|
|
# ---------------------------
|
|
|
|
def find_series_path_from_sonarr_path(sonarr_path: str) -> Optional[Path]:
|
|
if not sonarr_path:
|
|
return None
|
|
if sonarr_path.startswith("/mnt/unionfs/Media/TV/"):
|
|
container_path = sonarr_path.replace("/mnt/unionfs/Media/TV/", "/media/")
|
|
path = Path(container_path)
|
|
if path.exists():
|
|
_log("INFO", f"Found series path via Sonarr path: {path}")
|
|
return path
|
|
return None
|
|
|
|
def find_movie_path_from_radarr_path(radarr_path: str) -> Optional[Path]:
|
|
if not radarr_path:
|
|
return None
|
|
if radarr_path.startswith("/mnt/unionfs/Media/Movies/"):
|
|
container_path = radarr_path.replace("/mnt/unionfs/Media/Movies/", "/media/")
|
|
path = Path(container_path)
|
|
if path.exists():
|
|
_log("INFO", f"Found movie path via Radarr path: {path}")
|
|
return path
|
|
return None
|
|
|
|
def find_series_path(series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]:
|
|
if sonarr_path:
|
|
series_path = find_series_path_from_sonarr_path(sonarr_path)
|
|
if series_path:
|
|
return series_path
|
|
|
|
if imdb_id and not imdb_id.startswith("tt"):
|
|
imdb_id = f"tt{imdb_id}"
|
|
|
|
_log("DEBUG", f"Searching for series: {series_title} with IMDb ID: {imdb_id}")
|
|
|
|
for media_path in config.tv_paths:
|
|
if not media_path.exists():
|
|
_log("WARNING", f"TV path does not exist: {media_path}")
|
|
continue
|
|
|
|
if imdb_id:
|
|
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
|
|
matches = glob.glob(pattern)
|
|
if matches:
|
|
found_path = Path(matches[0])
|
|
_log("INFO", f"Found series directory by IMDb ID: {found_path}")
|
|
return found_path
|
|
|
|
if series_title:
|
|
series_title_clean = series_title.lower().replace(" ", "").replace("-", "").replace(":", "")
|
|
for item in media_path.iterdir():
|
|
if item.is_dir() and "[imdb-" in item.name.lower():
|
|
item_clean = item.name.lower().replace(" ", "").replace("-", "").replace(":", "")
|
|
if series_title_clean in item_clean:
|
|
_log("INFO", f"Found series directory by title: {item}")
|
|
return item
|
|
|
|
_log("WARNING", f"Could not find directory for series: {series_title} ({imdb_id})")
|
|
return None
|
|
|
|
def find_movie_path(movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
|
|
if radarr_path:
|
|
movie_path = find_movie_path_from_radarr_path(radarr_path)
|
|
if movie_path:
|
|
return movie_path
|
|
|
|
if imdb_id and not imdb_id.startswith("tt"):
|
|
imdb_id = f"tt{imdb_id}"
|
|
|
|
_log("DEBUG", f"Searching for movie: {movie_title} with IMDb ID: {imdb_id}")
|
|
|
|
for media_path in config.movie_paths:
|
|
if not media_path.exists():
|
|
_log("WARNING", f"Movie path does not exist: {media_path}")
|
|
continue
|
|
|
|
if imdb_id:
|
|
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
|
|
matches = glob.glob(pattern)
|
|
if matches:
|
|
found_path = Path(matches[0])
|
|
_log("INFO", f"Found movie directory by IMDb ID: {found_path}")
|
|
return found_path
|
|
|
|
if movie_title:
|
|
movie_title_clean = movie_title.lower().replace(" ", "").replace("-", "").replace(":", "")
|
|
for item in media_path.iterdir():
|
|
if item.is_dir() and "[imdb-" in item.name.lower():
|
|
item_clean = item.name.lower().replace(" ", "").replace("-", "").replace(":", "")
|
|
if movie_title_clean in item_clean:
|
|
_log("INFO", f"Found movie directory by title: {item}")
|
|
return item
|
|
|
|
_log("WARNING", f"Could not find directory for movie: {movie_title} ({imdb_id})")
|
|
return None
|
|
|
|
# ---------------------------
|
|
# Webhook processors
|
|
# ---------------------------
|
|
|
|
async def process_sonarr_import(webhook_data: SonarrWebhook):
|
|
try:
|
|
if not webhook_data.series:
|
|
_log("WARNING", f"No series data in {webhook_data.eventType} webhook")
|
|
return
|
|
|
|
series_info = webhook_data.series
|
|
series_title = series_info.get("title", "Unknown")
|
|
imdb_id = series_info.get("imdbId", "")
|
|
sonarr_path = series_info.get("path", "")
|
|
|
|
if imdb_id:
|
|
imdb_id = imdb_id.replace("tt", "").strip()
|
|
if imdb_id:
|
|
imdb_id = f"tt{imdb_id}"
|
|
|
|
if not imdb_id:
|
|
_log("ERROR", f"No IMDb ID found for series: {series_title}")
|
|
return
|
|
|
|
_log("INFO", f"Received webhook for series: {series_title} (IMDb: {imdb_id})")
|
|
|
|
series_path = find_series_path(series_title, imdb_id, sonarr_path)
|
|
if not series_path:
|
|
_log("ERROR", f"Could not find series directory for: {series_title} ({imdb_id})")
|
|
return
|
|
|
|
webhook_dict = {
|
|
'series': series_info,
|
|
'series_path': str(series_path),
|
|
'event_type': webhook_data.eventType
|
|
}
|
|
|
|
batcher.add_webhook(imdb_id, webhook_dict, 'tv')
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Error processing Sonarr webhook: {e}")
|
|
raise
|
|
|
|
async def process_radarr_import(webhook_data: RadarrWebhook):
|
|
try:
|
|
if not webhook_data.movie:
|
|
_log("WARNING", f"No movie data in {webhook_data.eventType} webhook")
|
|
return
|
|
|
|
movie_info = webhook_data.movie
|
|
movie_title = movie_info.get("title", "Unknown")
|
|
radarr_path = movie_info.get("folderPath") or movie_info.get("path", "")
|
|
|
|
imdb_id = (movie_info.get("imdbId") or (webhook_data.remoteMovie or {}).get("imdbId") or "").strip()
|
|
if imdb_id:
|
|
imdb_id = f"tt{imdb_id.replace('tt','')}"
|
|
|
|
movie_path = find_movie_path(movie_title, imdb_id, radarr_path)
|
|
if not imdb_id and movie_path:
|
|
parsed = parse_imdb_from_movie_path(movie_path)
|
|
if parsed:
|
|
imdb_id = parsed
|
|
|
|
if not imdb_id:
|
|
_log("WARNING", f"No IMDb ID available for movie '{movie_title}'. Proceeding by path only.")
|
|
|
|
_log("INFO", f"Received webhook for movie: {movie_title} (IMDb: {imdb_id or 'unknown'})")
|
|
if webhook_data.renamedMovieFiles:
|
|
_log("INFO", f"Renamed {len(webhook_data.renamedMovieFiles)} file(s) for {movie_title}")
|
|
|
|
if not movie_path:
|
|
_log("ERROR", f"Could not find movie directory for: {movie_title} ({imdb_id or 'unknown'})")
|
|
return
|
|
|
|
webhook_dict = {
|
|
'movie': movie_info,
|
|
'movie_path': str(movie_path),
|
|
'event_type': webhook_data.eventType
|
|
}
|
|
|
|
batcher.add_webhook(imdb_id or str(movie_path), webhook_dict, 'movie')
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Error processing Radarr webhook: {e}")
|
|
raise
|
|
|
|
# ---------------------------
|
|
# Payload reader (json or form)
|
|
# ---------------------------
|
|
|
|
async def _read_payload(request: Request) -> dict:
|
|
ctype = (request.headers.get("content-type") or "").lower()
|
|
try:
|
|
if "application/json" in ctype:
|
|
return await request.json()
|
|
form = await request.form()
|
|
if "payload" in form:
|
|
return json.loads(form["payload"])
|
|
return dict(form)
|
|
except Exception as e:
|
|
_log("ERROR", f"Failed to read webhook payload: {e}")
|
|
return {}
|
|
|
|
# ---------------------------
|
|
# Routes
|
|
# ---------------------------
|
|
|
|
@app.post("/webhook/sonarr")
|
|
async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
_log("INFO", "Hit /webhook/sonarr")
|
|
try:
|
|
payload = await _read_payload(request)
|
|
if not payload:
|
|
raise HTTPException(status_code=422, detail="Empty or unreadable Sonarr payload")
|
|
|
|
_log("DEBUG", f"Raw Sonarr webhook payload: {payload}")
|
|
webhook = SonarrWebhook(**payload)
|
|
_log("INFO", f"Received Sonarr webhook: {webhook.eventType}")
|
|
|
|
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
|
|
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
|
|
|
await process_sonarr_import(webhook)
|
|
return {"status": "accepted", "message": "Sonarr webhook queued for batched processing"}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
_log("ERROR", f"Failed to parse Sonarr webhook: {e}")
|
|
raise HTTPException(status_code=422, detail=f"Invalid Sonarr webhook format: {e}")
|
|
|
|
@app.post("/webhook/radarr")
|
|
async def radarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
_log("INFO", "Hit /webhook/radarr")
|
|
try:
|
|
payload = await _read_payload(request)
|
|
if not payload:
|
|
raise HTTPException(status_code=422, detail="Empty or unreadable Radarr payload")
|
|
|
|
_log("DEBUG", f"Raw Radarr webhook payload: {payload}")
|
|
webhook = RadarrWebhook(**payload)
|
|
_log("INFO", f"Received Radarr webhook: {webhook.eventType}")
|
|
|
|
if webhook.eventType not in ["Download", "Upgrade", "Rename", "MovieFileDelete", "MovieDelete", "Test"]:
|
|
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
|
|
|
if webhook.eventType != "Test":
|
|
await process_radarr_import(webhook)
|
|
return {"status": "accepted", "message": "Radarr webhook queued for batched processing"}
|
|
else:
|
|
return {"status": "ok", "message": "Radarr test received"}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
_log("ERROR", f"Failed to parse Radarr webhook: {e}")
|
|
raise HTTPException(status_code=422, detail=f"Invalid Radarr webhook format: {e}")
|
|
|
|
@app.get("/health")
|
|
async def health_check() -> HealthResponse:
|
|
uptime = datetime.now(timezone.utc) - start_time
|
|
try:
|
|
conn = db_connect(db_path)
|
|
conn.execute("PRAGMA busy_timeout = 5000")
|
|
conn.execute("SELECT 1").fetchone()
|
|
conn.close()
|
|
db_status = "healthy"
|
|
except Exception as e:
|
|
db_status = f"error: {e}"
|
|
|
|
return HealthResponse(
|
|
status="healthy" if db_status == "healthy" else "degraded",
|
|
version="1.7.0",
|
|
uptime=str(uptime),
|
|
database_status=db_status
|
|
)
|
|
|
|
@app.get("/stats")
|
|
async def get_stats():
|
|
try:
|
|
conn = db_connect(db_path)
|
|
conn.execute("PRAGMA busy_timeout = 5000")
|
|
ensure_movie_tables(conn)
|
|
|
|
tv_series_count = conn.execute("SELECT COUNT(*) FROM series").fetchone()[0]
|
|
tv_episode_count = conn.execute("SELECT COUNT(*) FROM episode_dates").fetchone()[0]
|
|
tv_with_files = conn.execute("SELECT COUNT(*) FROM episode_dates WHERE has_video_file = 1").fetchone()[0]
|
|
|
|
movie_count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0]
|
|
movies_with_files = conn.execute("SELECT COUNT(*) FROM movie_dates WHERE has_video_file = 1").fetchone()[0]
|
|
|
|
tv_sources = conn.execute("""
|
|
SELECT source, COUNT(*) as count
|
|
FROM episode_dates
|
|
WHERE has_video_file = 1
|
|
GROUP BY source
|
|
ORDER BY count DESC
|
|
""").fetchall()
|
|
|
|
movie_sources = conn.execute("""
|
|
SELECT source, COUNT(*) as count
|
|
FROM movie_dates
|
|
WHERE has_video_file = 1
|
|
GROUP BY source
|
|
ORDER BY count DESC
|
|
""").fetchall()
|
|
|
|
conn.close()
|
|
|
|
return {
|
|
"tv_series_count": tv_series_count,
|
|
"tv_episode_count": tv_episode_count,
|
|
"tv_episodes_with_files": tv_with_files,
|
|
"movie_count": movie_count,
|
|
"movies_with_files": movies_with_files,
|
|
"tv_sources": dict(tv_sources),
|
|
"movie_sources": dict(movie_sources)
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.get("/batch/status")
|
|
async def get_batch_status():
|
|
return batcher.get_status()
|
|
|
|
@app.get("/debug/movie/{imdb_id}")
|
|
async def debug_movie_import_date(imdb_id: str):
|
|
"""Debug endpoint to analyze movie import date detection"""
|
|
try:
|
|
if not imdb_id.startswith("tt"):
|
|
imdb_id = f"tt{imdb_id}"
|
|
|
|
_log("INFO", f"=== DEBUG MOVIE IMPORT DATE: {imdb_id} ===")
|
|
|
|
if not (config.radarr_url and config.radarr_api_key):
|
|
return {
|
|
"error": "Radarr not configured",
|
|
"imdb_id": imdb_id,
|
|
"radarr_configured": False
|
|
}
|
|
|
|
# Create Radarr client
|
|
rc = RadarrClient(config.radarr_url, config.radarr_api_key, config.radarr_timeout, config.radarr_retries)
|
|
|
|
# Look up movie
|
|
movie_obj = rc.movie_by_imdb(imdb_id)
|
|
if not movie_obj:
|
|
return {
|
|
"error": f"Movie not found in Radarr for IMDb ID {imdb_id}",
|
|
"imdb_id": imdb_id,
|
|
"radarr_configured": True,
|
|
"movie_found": False
|
|
}
|
|
|
|
movie_id = movie_obj.get("id")
|
|
movie_title = movie_obj.get("title")
|
|
|
|
_log("INFO", f"Found movie: {movie_title} (Radarr ID: {movie_id})")
|
|
|
|
# Get import history with detailed logging
|
|
import_date = rc.earliest_import_event(movie_id)
|
|
|
|
# Get movie files for additional context
|
|
movie_files = rc.movie_files(movie_id)
|
|
file_dates = []
|
|
for f in movie_files:
|
|
file_dates.append({
|
|
"id": f.get("id"),
|
|
"relativePath": f.get("relativePath"),
|
|
"dateAdded": f.get("dateAdded"),
|
|
"quality": f.get("quality", {}).get("quality", {}).get("name", "Unknown")
|
|
})
|
|
|
|
# Get ALL history pages - July events might be in older pages
|
|
raw_history = []
|
|
try:
|
|
page = 1
|
|
all_records = []
|
|
|
|
# Fetch multiple pages to find older events
|
|
while page <= 10: # Get up to 10 pages (500 records)
|
|
data = rc._get("/api/v3/history", {
|
|
"movieId": movie_id, "sortKey": "date", "sortDirection": "ascending",
|
|
"pageSize": 50, "page": page
|
|
})
|
|
|
|
if not data:
|
|
break
|
|
|
|
records = data if isinstance(data, list) else data.get("records", [])
|
|
if not records:
|
|
break
|
|
|
|
all_records.extend(records)
|
|
|
|
# If we got less than 50, we've reached the end
|
|
if len(records) < 50:
|
|
break
|
|
|
|
page += 1
|
|
|
|
_log("INFO", f"Fetched {len(all_records)} total history records across {page-1} pages for debug")
|
|
|
|
for record in all_records:
|
|
event_type = record.get("eventType", "")
|
|
date_str = record.get("date", "")
|
|
event_data = record.get("data", {})
|
|
|
|
# Try multiple ways to get source path info
|
|
source_path = (
|
|
event_data.get('sourcePath', '') or
|
|
event_data.get('path', '') or
|
|
event_data.get('sourceTitle', '') or
|
|
record.get('sourcePath', '') or
|
|
record.get('sourceTitle', '')
|
|
)
|
|
|
|
# Check if this looks like a real import
|
|
is_import_type = any(word in event_type.lower() for word in ['import', 'download'])
|
|
is_from_downloads = any(indicator in source_path.lower() for indicator in [
|
|
'/downloads/', '/download/', '/completed/', '/nzbs/', '/torrents/',
|
|
'nzbget', 'sabnzbd', 'radarr', 'completed'
|
|
])
|
|
|
|
# Check for July 7 events specifically
|
|
is_july_7 = "2025-07-07" in date_str or "2025-07-08" in date_str # Include July 8 for timezone differences
|
|
|
|
raw_history.append({
|
|
"eventType": event_type,
|
|
"date": date_str,
|
|
"sourcePath": source_path,
|
|
"fullData": event_data, # Include full data for analysis
|
|
"isImportType": is_import_type,
|
|
"isFromDownloads": is_from_downloads,
|
|
"isRealImport": is_import_type and is_from_downloads,
|
|
"isJuly7Event": is_july_7
|
|
})
|
|
|
|
except Exception as e:
|
|
raw_history = [{"error": f"Could not fetch raw history: {e}"}]
|
|
|
|
return {
|
|
"imdb_id": imdb_id,
|
|
"radarr_configured": True,
|
|
"movie_found": True,
|
|
"movie_title": movie_title,
|
|
"movie_id": movie_id,
|
|
"detected_import_date": import_date,
|
|
"movie_files": file_dates,
|
|
"raw_history": raw_history,
|
|
"analysis": {
|
|
"import_date_found": import_date is not None,
|
|
"expected_july_7_2025": "2025-07-07" in (import_date or ""),
|
|
"method_used": "radarr:history.import" if import_date else "none",
|
|
"march_24_is_add_date": "2025-03-24" in (import_date or ""),
|
|
"digital_release_matches_july_7": "2025-07-07" in (movie_obj.get("digitalRelease") or "")
|
|
},
|
|
"debug_info": {
|
|
"radarr_url": config.radarr_url,
|
|
"movie_digital_release": movie_obj.get("digitalRelease"),
|
|
"movie_in_cinemas": movie_obj.get("inCinemas"),
|
|
"movie_physical_release": movie_obj.get("physicalRelease")
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Debug endpoint error for {imdb_id}: {e}")
|
|
return {
|
|
"error": str(e),
|
|
"imdb_id": imdb_id,
|
|
"success": False
|
|
}
|
|
|
|
# ---------------------------
|
|
# Manual scans
|
|
# ---------------------------
|
|
|
|
def run_manual_scan_sync(scan_paths: List[Path], scan_type: str = "both"):
|
|
from media_date_cache import iterate_target_path
|
|
|
|
class MockArgs:
|
|
def __init__(self):
|
|
self.manage_nfo = config.manage_nfo
|
|
self.fix_dir_mtimes = config.fix_dir_mtimes
|
|
self.lock_metadata = config.lock_metadata
|
|
self.use_cache = True
|
|
self.force_refresh = False
|
|
self.skip_if_current = True
|
|
self.debug = config.debug
|
|
self.update_video_mtimes = True
|
|
|
|
conn = None
|
|
try:
|
|
conn = db_connect(db_path)
|
|
conn.execute("PRAGMA busy_timeout = 30000")
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
ensure_movie_tables(conn)
|
|
|
|
for scan_path in scan_paths:
|
|
_log("INFO", f"Starting manual scan of: {scan_path}")
|
|
|
|
if scan_type in ["both", "tv"]:
|
|
# Only run TV scan on paths that are in tv_paths
|
|
if scan_path in config.tv_paths:
|
|
tv_targets = iterate_target_path(scan_path)
|
|
for series_dir in tv_targets:
|
|
try:
|
|
apply_series(series_dir, MockArgs(), conn)
|
|
except Exception as e:
|
|
_log("ERROR", f"Failed processing TV series {series_dir}: {e}")
|
|
|
|
if scan_type in ["both", "movies"]:
|
|
if scan_path in config.movie_paths:
|
|
for item in scan_path.iterdir():
|
|
if is_movie_dir(item):
|
|
try:
|
|
apply_movie(item, conn)
|
|
except Exception as e:
|
|
_log("ERROR", f"Failed processing movie {item}: {e}")
|
|
|
|
_log("INFO", f"Completed manual scan of: {scan_path}")
|
|
finally:
|
|
if conn:
|
|
try:
|
|
conn.close()
|
|
except:
|
|
pass
|
|
|
|
@app.post("/manual/scan")
|
|
async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"):
|
|
if scan_type not in ["both", "tv", "movies"]:
|
|
raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'")
|
|
|
|
if path:
|
|
scan_paths = [Path(path)]
|
|
else:
|
|
if scan_type == "movies":
|
|
scan_paths = list(config.movie_paths)
|
|
elif scan_type == "tv":
|
|
scan_paths = list(config.tv_paths)
|
|
else:
|
|
scan_paths = list(config.tv_paths) + list(config.movie_paths)
|
|
|
|
async def run_scan():
|
|
await asyncio.get_event_loop().run_in_executor(None, run_manual_scan_sync, scan_paths, scan_type)
|
|
|
|
background_tasks.add_task(run_scan)
|
|
return {"status": "started", "message": f"Manual {scan_type} scan started for {len(scan_paths)} paths"}
|
|
|
|
@app.post("/manual/scan/tv")
|
|
async def manual_scan_tv(background_tasks: BackgroundTasks, path: Optional[str] = None):
|
|
return await manual_scan(background_tasks, path, "tv")
|
|
|
|
@app.post("/manual/scan/movies")
|
|
async def manual_scan_movies(background_tasks: BackgroundTasks, path: Optional[str] = None):
|
|
return await manual_scan(background_tasks, path, "movies")
|
|
|
|
# ---------------------------
|
|
# Main
|
|
# ---------------------------
|
|
|
|
if __name__ == "__main__":
|
|
if config.debug:
|
|
import logging
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
os.environ["DEBUG"] = "true"
|
|
|
|
_log("INFO", "Starting NFOGuard Webhook Server")
|
|
_log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}")
|
|
_log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}")
|
|
_log("INFO", f"Database path: {db_path}")
|
|
_log("INFO", f"Manage NFO: {config.manage_nfo}")
|
|
_log("INFO", f"Fix dir mtimes: {config.fix_dir_mtimes}")
|
|
_log("INFO", f"Lock metadata: {config.lock_metadata}")
|
|
_log("INFO", f"Batch delay: {config.batch_delay}s")
|
|
_log("INFO", f"Max concurrent items: {config.max_concurrent_series}")
|
|
_log("INFO", f"MOVIE_PRIORITY={config.movie_priority}")
|
|
_log("INFO", f"MOVIE_POLL_MODE={config.movie_poll_mode}")
|
|
_log("INFO", f"MOVIE_DATE_STRATEGY={config.movie_date_strategy}")
|
|
_log("INFO", f"MOVIE_DATE_UPDATE_MODE={config.movie_date_update_mode} "
|
|
f"(preserve_import={config.movie_preserve_radarr_import}, "
|
|
f"preserve_existing={config.movie_preserve_existing})")
|
|
_log("INFO", f"MOVIE_DIGITAL_SANITY_CHECK={config.movie_digital_sanity_check} "
|
|
f"(ref={config.movie_digital_sanity_ref}, max_lag_days={config.movie_digital_max_lag_days}, "
|
|
f"invalid_fallback={config.movie_digital_invalid_fallback})")
|
|
if config.radarr_url and config.radarr_api_key:
|
|
_log("INFO", f"Radarr enabled @ {config.radarr_url} (timeout={config.radarr_timeout}s, retries={config.radarr_retries})")
|
|
if config.tmdb_api_key:
|
|
_log("INFO", f"TMDB enabled (country={config.tmdb_country})")
|
|
if config.omdb_api_key:
|
|
_log("INFO", "OMDb enabled")
|
|
if config.jelly_url and config.jelly_api:
|
|
_log("INFO", f"Jellyseerr enabled @ {config.jelly_url}")
|
|
|
|
uvicorn.run(
|
|
app, # pass the object, not "app:app"
|
|
host="0.0.0.0",
|
|
port=int(os.environ.get("PORT", "8080")),
|
|
reload=False
|
|
)
|
|
|