diff --git a/.env.example b/.env.example index 947fb8d..60b18db 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,52 @@ -# Sonarr +# ================================= +# NFOGuard - Simple Configuration +# ================================= + +# --- Required: Media Library Paths --- +# Comma-separated paths to your media libraries +TV_PATHS=/media/TV/tv, /media/TV/tv6 +MOVIE_PATHS=/media/Movies/movies, /media/Movies/movies6 + +# --- Required: Sonarr/Radarr Connection --- SONARR_URL=http://sonarr:8989 SONARR_API_KEY= -SONARR_TIMEOUT=45 -SONARR_RETRIES=3 RADARR_URL=http://radarr:7878 RADARR_API_KEY= -RADARR_TIMEOUT=45 -RADARR_RETRIES=3 +# --- Required: TMDB for Release Dates --- +# Get free API key from https://www.themoviedb.org/settings/api +TMDB_API_KEY= +TMDB_COUNTRY=US -# Gotify (optional) -GOTIFY_URL= -GOTIFY_TOKEN=-s +# --- Behavior Settings --- +# When to update existing dates: always, missing_only, never +UPDATE_MODE=always +# Update file modification times to match chosen date: update, leave_alone +MTIME_BEHAVIOR=update -#TMDB_API_KEY= -#TMDB_PRIMARY_COUNTRY=US -#TMDB_TIMEOUT=45 -#TMDB_RETRIES=3 - - -#OMDB_API_KEY= -TVDB_API_KEY= -# Batching configuration -BATCH_DELAY=5.0 # Seconds to wait for more webhooks -MAX_CONCURRENT_SERIES=3 # Max series proceossing simultaneously - -TV_PATHS=/media/TV/tv,/media/TV/tv6 -MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6 - -# Batching (optional) -#BATCH_DELAY=5.0 -#MAX_CONCURRENT_SERIES=3 - -# Features (optional) -MANAGE_NFO=true -FIX_DIR_MTIMES=true +# Lock metadata in NFO files to prevent overwrites: true, false LOCK_METADATA=true + +# Brand name in NFO comments +MANAGER_BRAND=NFOGuard + +# --- Optional: Path Mapping for Containers --- +# If your Radarr/Sonarr containers see different paths than your webhook server +# Format: container_path->host_path,another_container_path->another_host_path +RADARR_PATH_MAP=/mnt/unionfs/Media/Movies->/media/Movies/movies,/mnt/unionfs/Media/Movies6->/media/Movies/movies6 +SONARR_PATH_MAP=/mnt/unionfs/Media/TV->/media/TV/tv->/media/TV/tv6 + +# --- Optional: Additional Digital Release Sources --- +#OMDB_API_KEY=your_omdb_api_key_here +JELLYSEERR_URL=http://jellyseerr:5055 +JELLYSEERR_API_KEY= + +# --- Optional: Advanced Settings --- +BATCH_DELAY=5.0 +MAX_CONCURRENT_SERIES=3 +TIMEOUT_SECONDS=45 DEBUG=false +DB_PATH=/app/data/media_dates.db +PORT=8080 diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..8970354 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,26 @@ +services: + NFOguard: + build: . + container_name: NFOguard + restart: unless-stopped + user: "1000:1000" + networks: + - saltbox + ports: + - "8085:8080" + volumes: + - /mnt/unionfs/Media/:/media:rw + - ./data:/app/data:rw + - ./.env:/app/.env:ro + env_file: + - .env + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + saltbox: + external: true diff --git a/webhook_server.py b/webhook_server.py index c73f802..5a3c9b3 100644 --- a/webhook_server.py +++ b/webhook_server.py @@ -312,8 +312,11 @@ class RadarrClient: "sortKey": "date", "sortDirection": "ascending" }) if not data: + _log("DEBUG", f"No history data returned for movie_id {movie_id}") return None items = data if isinstance(data, list) else data.get("records") or [] + _log("DEBUG", f"Found {len(items)} history items for movie_id {movie_id}") + first_grab = None for it in items: ev = (it.get("eventType") or it.get("type") or "").lower() @@ -323,13 +326,27 @@ class RadarrClient: date_iso = datetime.fromisoformat(it["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds") except Exception: date_iso = None - if ev in ("downloadfolderimported", "moviefileimported"): + + _log("DEBUG", f"History event: type='{ev}', date={date_iso}, data={it.get('data', {})}") + + # Expanded event types for Radarr v4/v5 compatibility + import_events = { + "downloadfolderimported", "moviefileimported", "imported", + "downloadimported", "fileimported", "movieimported" + } + + if ev in import_events: if date_iso: + _log("INFO", f"Found import event '{ev}' at {date_iso} for movie_id {movie_id}") return date_iso if ev == "grabbed" and date_iso and first_grab is None: first_grab = date_iso + if first_grab: + _log("INFO", f"Using grab date {first_grab} as fallback for movie_id {movie_id}") return first_grab + + _log("WARNING", f"No usable import/grab events found for movie_id {movie_id}") return None # --------------------------- @@ -523,7 +540,11 @@ def upsert_movie_nfo( _ensure_child_text(root, "year", ymd.split("-")[0], overwrite=False) if dateadded: - _ensure_child_text(root, "dateadded", dateadded, overwrite=True) + 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) @@ -552,9 +573,16 @@ def _parse_date_to_iso(d: str) -> Optional[str]: try: if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d): dt = datetime.fromisoformat(d).replace(tzinfo=timezone.utc) - return dt.isoformat(timespec="seconds") - dt = datetime.fromisoformat(d.replace("Z", "+00:00")) - return dt.astimezone(timezone.utc).isoformat(timespec="seconds") + 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 @@ -587,21 +615,32 @@ def _strategy_movie_date_from_movie_obj(movie_obj: Dict[str, Any], which: str) - 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]]: @@ -654,15 +693,24 @@ def _decide_movie_date(imdb_id: str, movie_dir: Path, poll_external: bool) -> Tu def from_import_history() -> Tuple[Optional[str], str]: ensure_movie() - if poll_external and movie_id: - iso = _radarr_earliest_import_history_iso(movie_id) - if iso: - return iso, "radarr:history.import" - if poll_external and movie_id: - iso2 = _radarr_earliest_file_dateadded_iso(movie_id) - if iso2: - return iso2, "radarr:moviefile.dateAdded" - return None, "radarr:history" + if not (poll_external and movie_id): + _log("DEBUG", f"Skipping import history: poll_external={poll_external}, movie_id={movie_id}") + return None, "radarr:history.skipped" + + # 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}") + return iso, "radarr:history.import" + + # Fallback to moviefile dateAdded + iso2 = _radarr_earliest_file_dateadded_iso(movie_id) + if iso2: + _log("INFO", f"Using Radarr moviefile dateAdded: {iso2}") + return iso2, "radarr:moviefile.dateAdded" + + _log("WARNING", f"No import dates found for movie_id {movie_id}") + return None, "radarr:history.none" def from_radarr_field(which: str) -> Tuple[Optional[str], str]: ensure_movie() @@ -715,33 +763,67 @@ def _decide_movie_date(imdb_id: str, movie_dir: Path, poll_external: bool) -> Tu 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, falling back to file mtime") mtime_iso, mtime_src = from_file_mtime() - return mtime_iso, mtime_src, released_iso + 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() - return mtime_iso, mtime_src, released_iso + 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 @@ -854,7 +936,7 @@ def apply_movie(movie_dir: Path, conn: sqlite3.Connection): brand=config.manager_brand, ) - if config.fix_dir_mtimes and dateadded_iso: + if config.fix_dir_mtimes and dateadded_iso and dateadded_iso != "MANUAL_REVIEW_NEEDED": set_movie_file_mtime(movie_dir, dateadded_iso) conn.commit()