From 72614af928cf4f42e3476f9555dff915d86aa283 Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Sat, 4 Oct 2025 10:55:23 -0400 Subject: [PATCH] Remove monolithic nfoguard.py and add comprehensive debug logging - Remove 2467-line monolithic nfoguard.py file to prevent confusion - Add debug prints to identify why NFO metadata isn't being added - Track config.manage_nfo values and function calls - Debug dateadded parameter passing through all tiers - Update version to 2.0.2 - This will help identify if manage_nfo is disabled or function not called --- VERSION | 2 +- core/nfo_manager.py | 5 + nfoguard.py | 2468 --------------------------------- processors/movie_processor.py | 8 + 4 files changed, 14 insertions(+), 2469 deletions(-) delete mode 100644 nfoguard.py diff --git a/VERSION b/VERSION index 10bf840..f93ea0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 \ No newline at end of file +2.0.2 \ No newline at end of file diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 62de808..5a252c4 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -234,6 +234,8 @@ class NFOManager: nfo_path = movie_dir / "movie.nfo" print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}") + print(f"🔍 NFO path: {nfo_path}") + print(f"🔍 NFO exists: {nfo_path.exists()}") try: # Try to load existing NFO file @@ -302,11 +304,14 @@ class NFOManager: pass # Skip year if we can't extract it # Add dateadded at the end - THIS IS CRITICAL FOR EMBY PLUGIN + print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})") if dateadded: dateadded_elem = ET.Element("dateadded") dateadded_elem.text = dateadded movie.append(dateadded_elem) print(f"✅ Added dateadded to NFO: {dateadded}") + else: + print(f"❌ dateadded is empty/None, not adding to NFO") # Add lockdata at the very end if lock_metadata: diff --git a/nfoguard.py b/nfoguard.py deleted file mode 100644 index 5b33ba3..0000000 --- a/nfoguard.py +++ /dev/null @@ -1,2468 +0,0 @@ -#!/usr/bin/env python3 -""" -NFOGuard - Automated NFO file management for Radarr and Sonarr -Modular architecture with webhook processing and intelligent date handling -""" -import os -import json -import asyncio -import glob -import re -import logging -import logging.handlers -import xml.etree.ElementTree as ET -from pathlib import Path -from datetime import datetime, timezone, timedelta -from zoneinfo import ZoneInfo -from fastapi import FastAPI, HTTPException, BackgroundTasks, Request -from pydantic import BaseModel -from typing import Optional, Dict, Any, List, Set, Tuple -import threading -from concurrent.futures import ThreadPoolExecutor -import uvicorn -import signal -import sys - -# Import our new modular components -from core.database import NFOGuardDatabase -from core.nfo_manager import NFOManager -from core.path_mapper import PathMapper -from clients.radarr_client import RadarrClient -from clients.sonarr_client import SonarrClient -from clients.external_clients import ExternalClientManager - -# --------------------------- -# Configuration & Logging -# --------------------------- - -class TimezoneAwareFormatter(logging.Formatter): - """Formatter that respects the container timezone""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.timezone = self._get_local_timezone() - - def _get_local_timezone(self): - """Get the local timezone, respecting TZ environment variable""" - tz_name = os.environ.get('TZ', 'UTC') - - try: - # Try zoneinfo first (Python 3.9+) - return ZoneInfo(tz_name) - except ImportError: - # Fallback for older Python versions - try: - import pytz - return pytz.timezone(tz_name) - except: - # Final fallback to UTC - return timezone.utc - except: - # If zone name is invalid, fallback to UTC - return timezone.utc - - def formatTime(self, record, datefmt=None): - dt = datetime.fromtimestamp(record.created, tz=self.timezone) - if datefmt: - return dt.strftime(datefmt) - return dt.isoformat(timespec='seconds') - -def _setup_file_logging(): - """Setup file logging for NFOGuard""" - log_dir = Path(os.environ.get("LOG_DIR", "/app/data/logs")) - log_dir.mkdir(parents=True, exist_ok=True) - - logger = logging.getLogger("NFOGuard") - logger.setLevel(logging.DEBUG) - - file_handler = logging.handlers.RotatingFileHandler( - log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3 - ) - - formatter = TimezoneAwareFormatter( - '[%(asctime)s] %(levelname)s: %(message)s' - ) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - return logger - -def _mask_sensitive_data(msg: str) -> str: - """Mask API keys and other sensitive data in log messages""" - import re - - # List of patterns to mask - sensitive_patterns = [ - (r'api_key=([a-zA-Z0-9_\-]+)', r'api_key=***masked***'), - (r'password=([^\s&]+)', r'password=***masked***'), - (r'token=([a-zA-Z0-9_\-]+)', r'token=***masked***'), - (r'key=([a-zA-Z0-9_\-]{8,})', r'key=***masked***'), # Keys longer than 8 chars - (r'([a-zA-Z0-9]{32,})', lambda m: m.group(1)[:8] + '***masked***' if len(m.group(1)) > 16 else m.group(1)) # Long strings likely to be keys - ] - - masked_msg = msg - for pattern, replacement in sensitive_patterns: - if isinstance(replacement, str): - masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE) - else: - masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE) - - return masked_msg - -def _get_local_timezone(): - """Get the local timezone, respecting TZ environment variable""" - tz_name = os.environ.get('TZ', 'UTC') - - try: - # Try zoneinfo first (Python 3.9+) - return ZoneInfo(tz_name) - except ImportError: - # Fallback for older Python versions - try: - import pytz - return pytz.timezone(tz_name) - except: - # Final fallback to UTC - return timezone.utc - except: - # If zone name is invalid, fallback to UTC - return timezone.utc - -def _log(level: str, msg: str): - """Enhanced logging that writes to both console and file with sensitive data masking""" - masked_msg = _mask_sensitive_data(msg) - tz = _get_local_timezone() - print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {masked_msg}") - - try: - file_logger = _setup_file_logging() - getattr(file_logger, level.lower(), file_logger.info)(masked_msg) - except Exception as e: - print(f"File logging error: {e}") - -def convert_utc_to_local(utc_iso_string: str) -> str: - """Convert UTC ISO timestamp to local timezone timestamp""" - if not utc_iso_string: - return utc_iso_string - - try: - # Parse UTC timestamp - if utc_iso_string.endswith('Z'): - dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00')) - elif '+00:00' in utc_iso_string: - dt_utc = datetime.fromisoformat(utc_iso_string) - else: - # Assume UTC if no timezone info - dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc) - - # Convert to local timezone - local_tz = _get_local_timezone() - dt_local = dt_utc.astimezone(local_tz) - - return dt_local.isoformat(timespec='seconds') - except Exception: - # If conversion fails, return original - return utc_iso_string - -# Initialize logging -_setup_file_logging() - -def _load_environment_files(): - """Load environment variables from .env and optionally .env.secrets""" - from pathlib import Path - - # Try to load from python-dotenv if available - try: - from dotenv import load_dotenv - - # Load main .env file - env_file = Path(".env") - if env_file.exists(): - load_dotenv(env_file) - _log("INFO", f"Loaded environment from {env_file}") - - # Load secrets file if it exists - secrets_file = Path(".env.secrets") - if secrets_file.exists(): - load_dotenv(secrets_file) - _log("INFO", f"Loaded secrets from {secrets_file}") - - except ImportError: - _log("WARNING", "python-dotenv not available - environment files not loaded") - -# Load environment files at startup -_load_environment_files() - -# Add debug logging near where configuration is loaded -print(f"DEBUG: Environment check - SONARR_ROOT_FOLDERS: {os.getenv('SONARR_ROOT_FOLDERS')}") -print(f"DEBUG: Environment check - TV_PATHS: {os.getenv('TV_PATHS')}") - -def _bool_env(name: str, default: bool) -> bool: - v = os.environ.get(name) - if v is None: - return default - return v.lower() in ("1", "true", "yes", "y", "on") - -# --------------------------- -# Configuration -# --------------------------- - -class NFOGuardConfig: - def __init__(self): - # Paths - No hardcoded defaults, must be configured via environment - tv_paths_env = os.environ.get("TV_PATHS", "") - movie_paths_env = os.environ.get("MOVIE_PATHS", "") - - if not tv_paths_env: - raise ValueError("TV_PATHS environment variable is required but not set") - if not movie_paths_env: - raise ValueError("MOVIE_PATHS environment variable is required but not set") - - self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()] - self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()] - - # Core settings - 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.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard") - - # Batching - self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0")) - self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3")) - - # Database - self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db")) - - # Movie processing - self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower() - self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True) - self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False) - self.release_date_priority = [p.strip() for p in os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical").split(",")] - self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True) - self.max_release_date_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10")) - self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower() - self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower() - - # TV processing - self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}") - self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower() - self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower() - -config = NFOGuardConfig() - -# --------------------------- -# 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 - - 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 - - class Config: - extra = "allow" - -class HealthResponse(BaseModel): - status: str - version: str - uptime: str - database_status: str - radarr_database: Optional[Dict[str, Any]] = None - -class TVSeasonRequest(BaseModel): - series_path: str - season_name: str - -class TVEpisodeRequest(BaseModel): - series_path: str - season_name: str - episode_name: str - -# --------------------------- -# Core Processing -# --------------------------- - -class TVProcessor: - """Handles TV series processing""" - - def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): - self.db = db - self.nfo_manager = nfo_manager - self.path_mapper = path_mapper - self.sonarr = SonarrClient( - os.environ.get("SONARR_URL", ""), - os.environ.get("SONARR_API_KEY", "") - ) - self.external_clients = ExternalClientManager() - - def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]: - """Find series directory path""" - # Try webhook path first - if sonarr_path: - container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path) - path_obj = Path(container_path) - if path_obj.exists(): - return path_obj - - # Search by IMDb ID or title - for media_path in config.tv_paths: - if not media_path.exists(): - continue - - # Search by IMDb ID - if imdb_id: - pattern = str(media_path / f"*[imdb-{imdb_id}]*") - matches = glob.glob(pattern) - if matches: - return Path(matches[0]) - - # Search by title - if series_title: - title_clean = series_title.lower().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("-", "") - if title_clean in item_clean: - return item - - return None - - def process_series(self, series_path: Path) -> None: - """Process a TV series directory""" - imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) - if not imdb_id: - _log("ERROR", f"No IMDb ID found in series path: {series_path}") - return - - _log("INFO", f"Processing TV series: {series_path.name}") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) - - # Find video files - disk_episodes = self._find_disk_episodes(series_path) - _log("INFO", f"Found {len(disk_episodes)} episodes on disk") - - # Get episode dates - episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes) - - # Process episodes - for (season, episode), (aired, dateadded, source) in episode_dates.items(): - if (season, episode) in disk_episodes: - # Create NFO - if config.manage_nfo: - season_dir = series_path / config.tv_season_dir_format.format(season=season) - self.nfo_manager.create_episode_nfo( - season_dir, - season, episode, aired, dateadded, source, config.lock_metadata - ) - - # Update file mtimes - if config.fix_dir_mtimes and dateadded: - video_files = disk_episodes[(season, episode)] - for video_file in video_files: - self.nfo_manager.set_file_mtime(video_file, dateadded) - - # Save to database - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) - - # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs - pass - - _log("INFO", f"Completed processing TV series: {series_path.name}") - - def _extract_series_title_from_path(self, series_path: Path) -> Optional[str]: - """Extract series title from directory path, removing year and IMDb ID""" - name = series_path.name - - # Remove IMDb ID part: [imdb-ttXXXXXX] or [ttXXXXXX] - name = re.sub(r'\s*\[imdb-?tt\d+\]', '', name, flags=re.IGNORECASE) - name = re.sub(r'\s*\[tt\d+\]', '', name, flags=re.IGNORECASE) - - # Remove year in parentheses: (YYYY) - name = re.sub(r'\s*\(\d{4}\)', '', name) - - # Clean up extra spaces - name = re.sub(r'\s+', ' ', name).strip() - - return name if name else None - - def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]: - """Find all episode video files on disk""" - disk_episodes = {} - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - - _log("DEBUG", f"Scanning for episodes in: {series_path}") - _log("DEBUG", f"Looking for season dirs with pattern: '{config.tv_season_dir_pattern}'") - - for season_dir in series_path.iterdir(): - _log("DEBUG", f"Checking: {season_dir.name} (is_dir: {season_dir.is_dir()})") - if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)): - _log("DEBUG", f"Skipping {season_dir.name} - doesn't match season pattern") - continue - - try: - # Extract season number from directory name - # Handle formats like "Season 01", "S01", "Season01", etc. - dir_name = season_dir.name.lower() - if config.tv_season_dir_pattern in dir_name: - # Extract everything after the pattern - season_part = dir_name[len(config.tv_season_dir_pattern):].strip() - else: - continue - season_num = int(season_part) - except (ValueError, IndexError): - continue - - for video_file in season_dir.iterdir(): - if video_file.is_file() and video_file.suffix.lower() in video_exts: - match = re.search(r"S(\d{2})E(\d{2})", video_file.name, re.IGNORECASE) - if match: - file_season, file_episode = int(match.group(1)), int(match.group(2)) - key = (season_num, file_episode) # Use directory season number - if key not in disk_episodes: - disk_episodes[key] = [] - disk_episodes[key].append(video_file) - - return disk_episodes - - def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict) -> Dict: - """Gather episode dates from various sources""" - episode_dates = {} - - # Check cache first - cached_episodes = self.db.get_series_episodes(imdb_id, has_video_file_only=True) - for ep in cached_episodes: - key = (ep["season"], ep["episode"]) - if key in disk_episodes: # Only use cached data for episodes we have - episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"]) - - # Find missing episodes - cached_keys = set(episode_dates.keys()) - missing_keys = set(disk_episodes.keys()) - cached_keys - - if not missing_keys: - _log("INFO", "All episodes found in cache") - return episode_dates - - _log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes") - - # Query Sonarr for missing episodes - if self.sonarr.enabled: - series = self.sonarr.series_by_imdb(imdb_id) - if not series: - _log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}") - series = self.sonarr.series_by_imdb_direct(imdb_id) - if not series: - # Fallback: try searching by series title extracted from path - series_title = self._extract_series_title_from_path(series_path) - if series_title: - _log("DEBUG", f"Trying title search for: {series_title}") - series = self.sonarr.series_by_title(series_title) - if series: - series_id = series.get("id") - if series_id: - episodes = self.sonarr.episodes_for_series(series_id) - for ep in episodes: - season_num = ep.get("seasonNumber") - episode_num = ep.get("episodeNumber") - - if not isinstance(season_num, int) or not isinstance(episode_num, int): - continue - - key = (season_num, episode_num) - if key not in missing_keys: - continue - - # Get dates - aired = self._parse_date_to_iso(ep.get("airDateUtc")) - dateadded = None - source = "sonarr:episode.airDateUtc" - - # Try to get import history - episode_id = ep.get("id") - if episode_id: - import_date = self.sonarr.get_episode_import_history(episode_id) - if import_date: - dateadded = self._parse_date_to_iso(import_date) - source = "sonarr:history.import" - - if not dateadded: - dateadded = aired - - if aired or dateadded: - episode_dates[key] = (aired, dateadded, source) - - # Fill remaining gaps with external APIs - remaining_keys = missing_keys - set(episode_dates.keys()) - if remaining_keys and self.external_clients.tmdb.enabled: - tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id) - if tmdb_movie: - tv_id = tmdb_movie.get("id") - if tv_id: - seasons_needed = set(season for season, episode in remaining_keys) - for season_num in seasons_needed: - tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num) - for ep_num, air_date in tmdb_episodes.items(): - key = (season_num, ep_num) - if key in remaining_keys: - aired = self._parse_date_to_iso(air_date) - episode_dates[key] = (aired, aired, "tmdb:air_date") - - return episode_dates - - def _parse_date_to_iso(self, date_str: str) -> Optional[str]: - """Parse date string to ISO format""" - if not date_str: - return None - try: - if len(date_str) == 10 and date_str[4] == "-": - dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) - else: - dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) - return dt.isoformat(timespec="seconds") - except Exception: - return None - - def process_season(self, series_path: Path, season_path: Path) -> None: - """Process a specific TV season directory""" - imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) - if not imdb_id: - _log("ERROR", f"No IMDb ID found in series path: {series_path}") - return - - # Extract season number from path - season_match = re.search(r'season\s*(\d+)', season_path.name, re.IGNORECASE) - if not season_match: - _log("ERROR", f"Could not extract season number from: {season_path.name}") - return - - season_num = int(season_match.group(1)) - _log("INFO", f"Processing TV season {season_num}: {season_path}") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) - - # Find video files in this season only - disk_episodes = self._find_season_episodes(season_path, season_num) - _log("INFO", f"Found {len(disk_episodes)} episodes in season {season_num}") - - # Get enhanced metadata from Sonarr - series_metadata = self._get_sonarr_series_metadata(imdb_id) - - # Get episode dates for this season - episode_dates = self._gather_season_episode_dates(series_path, imdb_id, season_num, disk_episodes, series_metadata) - - # Process episodes - for (season, episode), (aired, dateadded, source) in episode_dates.items(): - if (season, episode) in disk_episodes: - # Get enhanced episode metadata - enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None - - # Create NFO - if config.manage_nfo: - self.nfo_manager.create_episode_nfo( - season_path, - season, episode, aired, dateadded, source, config.lock_metadata, - enhanced_metadata - ) - - # Update file mtimes - if config.fix_dir_mtimes and dateadded: - video_files = disk_episodes[(season, episode)] - for video_file in video_files: - self.nfo_manager.set_file_mtime(video_file, dateadded) - - # Save to database - self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) - - # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs - pass - - _log("INFO", f"Completed processing season {season_num}") - - def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None: - """Process a single TV episode file""" - imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) - if not imdb_id: - _log("ERROR", f"No IMDb ID found in series path: {series_path}") - return - - # Parse episode info from filename - episode_info = self._parse_episode_from_filename(episode_file.name) - if not episode_info: - _log("ERROR", f"Could not parse episode info from: {episode_file.name}") - return - - season_num, episode_num = episode_info - _log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) - - # TIER 1: Check if episode NFO already has NFOGuard data (fastest - no DB or API calls) - nfo_data = self.nfo_manager.extract_nfoguard_dates_from_episode_nfo(season_path, season_num, episode_num) - if nfo_data: - _log("INFO", f"🚀 Using existing NFOGuard data from episode NFO S{season_num:02d}E{episode_num:02d}: {nfo_data['dateadded']} (source: {nfo_data['source']})") - dateadded = nfo_data["dateadded"] - source = nfo_data["source"] - aired = nfo_data.get("aired") - - # Update file mtime if enabled (NFO is already correct) - if config.fix_dir_mtimes and dateadded: - self.nfo_manager.set_file_mtime(episode_file, dateadded) - - _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d} (source: {source}) [episode-nfo-only]") - return - - # TIER 2: Check database for existing episode data - existing_episode = self.db.get_episode_date(imdb_id, season_num, episode_num) - if existing_episode and existing_episode.get("dateadded") and existing_episode.get("source") != "no_valid_date_source": - _log("INFO", f"✅ Using complete database data for episode S{season_num:02d}E{episode_num:02d}: {existing_episode['dateadded']} (source: {existing_episode['source']})") - # Still create NFO and update files but skip API queries - dateadded = existing_episode["dateadded"] - source = existing_episode["source"] - aired = existing_episode.get("aired") - - # Create NFO with existing data (no enhanced metadata needed) - if config.manage_nfo and dateadded: - self.nfo_manager.create_episode_nfo( - season_path, - season_num, episode_num, aired, dateadded, source, config.lock_metadata, - None # No enhanced metadata for database-cached episodes - ) - - # Update file mtime if enabled - if config.fix_dir_mtimes and dateadded: - self.nfo_manager.set_file_mtime(episode_file, dateadded) - - _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d} (source: {source}) [episode-database-only]") - return - - # TIER 3: Full processing with Sonarr API calls (slowest) - _log("DEBUG", f"Episode S{season_num:02d}E{episode_num:02d} requires full processing - querying Sonarr APIs") - - # Get enhanced metadata from Sonarr - series_metadata = self._get_sonarr_series_metadata(imdb_id) - enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None - - # Get episode date - aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) - - # Create NFO (always create if manage_nfo is enabled, even without dateadded) - if config.manage_nfo: - self.nfo_manager.create_episode_nfo( - season_path, - season_num, episode_num, aired, dateadded, source, config.lock_metadata, - enhanced_metadata - ) - - # Update file mtime - if config.fix_dir_mtimes and dateadded: - self.nfo_manager.set_file_mtime(episode_file, dateadded) - - # Save to database - self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) - - _log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}") - - def _find_season_episodes(self, season_path: Path, season_num: int) -> Dict[Tuple[int, int], List[Path]]: - """Find all episode video files in a single season""" - disk_episodes = {} - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v", ".ts", ".m2ts") - - for file_path in season_path.iterdir(): - if not file_path.is_file() or file_path.suffix.lower() not in video_exts: - continue - - episode_info = self._parse_episode_from_filename(file_path.name) - if episode_info and episode_info[0] == season_num: - season, episode = episode_info - key = (season, episode) - if key not in disk_episodes: - disk_episodes[key] = [] - disk_episodes[key].append(file_path) - - return disk_episodes - - def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]: - """Parse season and episode numbers from filename""" - # Try SxxExx format - match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE) - if match: - return int(match.group(1)), int(match.group(2)) - - # Try season.episode format - match = re.search(r'(\d{1,2})\.(\d{1,2})', filename) - if match: - return int(match.group(1)), int(match.group(2)) - - return None - - def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: - """Get enhanced series metadata from Sonarr API""" - try: - if not self.sonarr.enabled: - return None - - series = self.sonarr.series_by_imdb(imdb_id) - if not series: - _log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}") - series = self.sonarr.series_by_imdb_direct(imdb_id) - if not series: - _log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}") - return None - - # Get episodes for the series - series_id = series.get("id") - if series_id: - episodes = self.sonarr.episodes_for_series(series_id) - series["episodes"] = episodes - _log("DEBUG", f"Got {len(episodes)} episodes from Sonarr for series {series.get('title')}") - - return series - - except Exception as e: - _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}") - return None - - def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]: - """Extract specific episode metadata from series data""" - if not series_metadata or "episodes" not in series_metadata: - return None - - for episode in series_metadata["episodes"]: - if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: - return { - "title": episode.get("title"), - "overview": episode.get("overview"), - "runtime": episode.get("runtime"), - "ratings": episode.get("ratings", {}) - } - - return None - - def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: - """Get episode dates for a specific season""" - episode_dates = {} - - for (season, episode) in disk_episodes.keys(): - if season != season_num: - continue - - aired, dateadded, source = self._get_single_episode_date(imdb_id, season, episode, series_metadata) - episode_dates[(season, episode)] = (aired, dateadded, source) - - return episode_dates - - def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: - """ - Get date info for a single episode during backfill scans. - Priority: 1) Database 2) Sonarr import history 3) Air dates - """ - # Step 1: Try database first - existing = self.db.get_episode_date(imdb_id, season_num, episode_num) - if existing and existing.get("dateadded"): - _log("DEBUG", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}") - return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") - - # Step 2: Try Sonarr import history (the real import date) - aired = None - import_date = None - - if self.sonarr.enabled: - try: - series = self.sonarr.series_by_imdb(imdb_id) - if not series: - _log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}") - series = self.sonarr.series_by_imdb_direct(imdb_id) - if not series: - # Try title search as fallback for IMDb mismatches - _log("DEBUG", f"Trying title search fallback for IMDb: {imdb_id}") - # We don't have series_path here, so just try the imdb_id as a title search - # This is less ideal but better than nothing - if series: - episodes = self.sonarr.episodes_for_series(series["id"]) - for ep in episodes: - if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: - aired = ep.get("airDateUtc") - import_date = self.sonarr.get_episode_import_history(ep["id"]) - if import_date: - # Convert import date to local timezone for NFO files - local_import_date = convert_utc_to_local(import_date) - _log("INFO", f"Found Sonarr import history for S{season_num:02d}E{episode_num:02d}: {local_import_date}") - return aired, local_import_date, "sonarr:history.import" - break - except Exception as e: - _log("DEBUG", f"Sonarr API error for episode S{season_num:02d}E{episode_num:02d}: {e}") - - # Step 3: Try Sonarr metadata for air date - if not aired and series_metadata and "episodes" in series_metadata: - for episode in series_metadata["episodes"]: - if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: - aired = episode.get("airDateUtc") - break - - # Step 4: Only if we have an air date but no import history, use air date as fallback - if aired: - _log("WARNING", f"No import history found for S{season_num:02d}E{episode_num:02d}, using air date as fallback") - return aired, aired, "sonarr:episode.airDateUtc" - - # Step 5: Last resort - current time in local timezone (shouldn't happen in normal operation) - local_tz = _get_local_timezone() - current_time = datetime.now(local_tz).isoformat(timespec="seconds") - _log("WARNING", f"No data found for S{season_num:02d}E{episode_num:02d}, using current time") - return None, current_time, "fallback:current_time" - - def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None: - """Process only the specific episodes mentioned in a webhook (targeted mode)""" - imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) - if not imdb_id: - _log("ERROR", f"No IMDb ID found in series path: {series_path}") - return - - if not webhook_episodes: - _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}") - self.process_series(series_path) - return - - _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") - - # Update database - self.db.upsert_series(imdb_id, str(series_path)) - - # Get enhanced metadata from Sonarr - series_metadata = self._get_sonarr_series_metadata(imdb_id) - - episodes_processed = 0 - for webhook_episode in webhook_episodes: - season_num = webhook_episode.get("seasonNumber") - episode_num = webhook_episode.get("episodeNumber") - - if not season_num or not episode_num: - _log("WARNING", f"Invalid episode data in webhook: {webhook_episode}") - continue - - # Check if episode file exists on disk - season_dir = series_path / config.tv_season_dir_format.format(season=season_num) - if not season_dir.exists(): - _log("WARNING", f"Season directory not found: {season_dir}") - continue - - # Find matching episode files - episode_files = [] - for file_path in season_dir.iterdir(): - if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'): - parsed = self._parse_episode_from_filename(file_path.name) - if parsed and parsed == (season_num, episode_num): - episode_files.append(file_path) - - if not episode_files: - _log("WARNING", f"No video files found for S{season_num:02d}E{episode_num:02d}") - continue - - # Get episode date information - webhook processing prioritizes existing DB entries - _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") - aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata) - enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None - - # Create NFO - if config.manage_nfo: - self.nfo_manager.create_episode_nfo( - season_dir, - season_num, episode_num, aired, dateadded, source, config.lock_metadata, - enhanced_metadata - ) - - # Update file mtimes - if config.fix_dir_mtimes and dateadded: - for episode_file in episode_files: - self.nfo_manager.set_file_mtime(episode_file, dateadded) - - # Save to database - self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) - - # Verify database entry was saved (debug) - verification = self.db.get_episode_date(imdb_id, season_num, episode_num) - if verification: - _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") - else: - _log("ERROR", f"Failed to save episode to database: S{season_num:02d}E{episode_num:02d}") - - episodes_processed += 1 - - # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs - pass - - _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") - - def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: - """ - Get episode date for webhook processing with correct priority: - 1. Check existing NFOGuard database entry (preserve previous import dates) - 2. If not found, use current time (webhook = new download) - 3. Get aired date from Sonarr/TMDB for reference - """ - # Step 1: Check if we already have this episode in our database - _log("DEBUG", f"Checking database for existing episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") - existing = self.db.get_episode_date(imdb_id, season_num, episode_num) - if existing: - _log("DEBUG", f"Found database entry: {existing}") - if existing.get("dateadded"): - _log("INFO", f"Using existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']} (source: {existing.get('source', 'unknown')})") - return existing.get("aired"), existing["dateadded"], existing.get("source", "database:existing") - else: - _log("DEBUG", f"Database entry found but no dateadded value: {existing}") - else: - _log("DEBUG", f"No database entry found for IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") - - # Step 2: Webhook = Source of Truth - use current timestamp in local timezone - local_tz = _get_local_timezone() - current_time = datetime.now(local_tz).isoformat(timespec="seconds") - _log("INFO", f"Webhook episode processing - using current timestamp as source of truth: {current_time}") - - # Step 3: Get aired date for reference (but don't use for dateadded) - aired = None - - # Try Sonarr metadata first for air date - if series_metadata and "episodes" in series_metadata: - for episode in series_metadata["episodes"]: - if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: - aired = episode.get("airDateUtc") - if aired: - break - - # Fallback to Sonarr API for air date if not in metadata - if not aired and self.sonarr.enabled: - try: - series = self.sonarr.series_by_imdb(imdb_id) - if not series: - _log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}") - series = self.sonarr.series_by_imdb_direct(imdb_id) - if series: - episodes = self.sonarr.episodes_for_series(series["id"]) - for ep in episodes: - if ep.get("seasonNumber") == season_num and ep.get("episodeNumber") == episode_num: - aired = ep.get("airDateUtc") - break - except Exception as e: - _log("DEBUG", f"Error getting air date from Sonarr: {e}") - - return aired, current_time, "webhook:first_seen" - - -class MovieProcessor: - """Handles movie processing""" - - def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper): - self.db = db - self.nfo_manager = nfo_manager - self.path_mapper = path_mapper - self.radarr = RadarrClient( - os.environ.get("RADARR_URL", ""), - os.environ.get("RADARR_API_KEY", "") - ) - self.external_clients = ExternalClientManager() - - def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]: - """Find movie directory path""" - # Try webhook path first - if radarr_path: - container_path = self.path_mapper.radarr_path_to_container_path(radarr_path) - path_obj = Path(container_path) - if path_obj.exists(): - return path_obj - - # Search by IMDb ID or title - for media_path in config.movie_paths: - if not media_path.exists(): - continue - - # Search by IMDb ID - if imdb_id: - pattern = str(media_path / f"*[imdb-{imdb_id}]*") - matches = glob.glob(pattern) - if matches: - return Path(matches[0]) - - # Search by title - if movie_title: - title_clean = movie_title.lower().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("-", "") - if title_clean in item_clean: - return item - - return None - - def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None: - """Process a movie directory""" - imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path) - if not imdb_id: - _log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}") - return - - # Handle TMDB ID fallback case - is_tmdb_fallback = imdb_id.startswith("tmdb-") - if is_tmdb_fallback: - _log("INFO", f"Processing movie: {movie_path.name} (TMDB: {imdb_id})") - else: - _log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})") - - # Update database - self.db.upsert_movie(imdb_id, str(movie_path)) - - # Check for video files - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir()) - - if not has_video: - _log("WARNING", f"No video files found in: {movie_path}") - self.db.upsert_movie_dates(imdb_id, None, None, None, False) - return - - # TIER 1: Check if NFO file already has NFOGuard data (fastest - no DB or API calls) - nfo_path = movie_path / "movie.nfo" - nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path) - if nfo_data: - _log("INFO", f"🚀 Using existing NFOGuard data from NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})") - dateadded = nfo_data["dateadded"] - source = nfo_data["source"] - released = nfo_data.get("released") - - # Update file mtimes if enabled (NFO is already correct) - if config.fix_dir_mtimes and dateadded: - self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) - - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-only]") - return - - # TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO - if is_tmdb_fallback: - tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path) - if tmdb_nfo_data: - _log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})") - dateadded = tmdb_nfo_data["dateadded"] - source = tmdb_nfo_data["source"] - released = tmdb_nfo_data.get("released") - - # Create NFO with NFOGuard fields added - if config.manage_nfo: - self.nfo_manager.create_movie_nfo( - movie_path, imdb_id, dateadded, released, source, config.lock_metadata - ) - - # Update file mtimes if enabled - if config.fix_dir_mtimes and dateadded: - self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) - - # Save to database - self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True) - - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo]") - return - - # TIER 2: Check database for existing data - existing = self.db.get_movie_dates(imdb_id) - _log("DEBUG", f"Database lookup for {imdb_id}: {existing}") - - # If we have complete data in database, use it and skip expensive API calls - if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source": - _log("INFO", f"✅ Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})") - # Still create NFO and update files but skip API queries - dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") - - # Create NFO with existing data - if config.manage_nfo: - self.nfo_manager.create_movie_nfo( - movie_path, imdb_id, dateadded, released, source, config.lock_metadata - ) - - # Update file mtimes if enabled - if config.fix_dir_mtimes and dateadded: - self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) - - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-only]") - return - - # Handle webhook mode - prioritize database, then use proper date logic - if webhook_mode: - _log("DEBUG", f"Webhook mode: existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else 'N/A'}") - if existing and existing.get("dateadded"): - _log("INFO", f"Webhook processing - using existing database entry: {existing['dateadded']} (source: {existing.get('source', 'unknown')})") - dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") - else: - if existing: - _log("INFO", f"Webhook processing - database entry exists but no dateadded field: {existing}") - else: - _log("INFO", f"Webhook processing - no database entry found for {imdb_id}") - _log("INFO", f"Using full date decision logic") - # Use same logic as manual scan to check Radarr import dates, release dates, etc. - should_query = True # Always query for webhooks when no database entry exists - dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) - - # Only if ALL date sources fail, fall back to current timestamp - if dateadded is None: - local_tz = _get_local_timezone() - current_time = datetime.now(local_tz).isoformat(timespec="seconds") - _log("INFO", f"Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}") - dateadded, source = current_time, "webhook:fallback_timestamp" - else: - # Manual scan mode - determine if we should query APIs - should_query = ( - config.movie_poll_mode == "always" or - (config.movie_poll_mode == "if_missing" and not existing) or - (config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime") or - (config.movie_poll_mode == "if_missing" and existing and not existing.get("dateadded")) - ) - - _log("DEBUG", f"Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}, existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else False}") - - # Use existing movie date decision logic - dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing) - - # If we don't have an import/download date but we have a release date, use it as dateadded - # This ensures we save digital release dates, theatrical dates, etc. to the database - final_dateadded = dateadded - final_source = source - - if dateadded is None and released is not None: - final_dateadded = released - final_source = f"{source}_as_dateadded" if source else "release_date_fallback" - _log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})") - - # Create NFO regardless of date availability (preserves existing metadata) - if config.manage_nfo: - self.nfo_manager.create_movie_nfo( - movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata - ) - - # Skip remaining processing if no valid date found and file dates disabled - if final_dateadded is None: - _log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed") - self.db.upsert_movie_dates(imdb_id, released, None, source, True) - return - - # Update dateadded and source for the rest of processing - dateadded = final_dateadded - source = final_source - - _log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}") - - # Update file mtimes (only if we have a valid date) - if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED": - self.nfo_manager.update_movie_files_mtime(movie_path, dateadded) - - _log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}") - - # Save to database - _log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}") - try: - self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True) - _log("DEBUG", f"Database save completed for {imdb_id}") - except Exception as e: - _log("ERROR", f"Database save failed for {imdb_id}: {e}") - raise - - _log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})") - - def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]: - """Extract date information from TMDB-based NFO file""" - if not nfo_path.exists(): - return None - - try: - root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path) - - # Look for premiered date (from TMDB) - premiered_elem = root.find('.//premiered') - if premiered_elem is not None and premiered_elem.text: - premiered_date = premiered_elem.text.strip() - print(f"✅ Found TMDB premiered date: {premiered_date}") - - return { - "dateadded": premiered_date, - "source": "tmdb:premiered_from_nfo", - "released": premiered_date - } - - except (ET.ParseError, Exception) as e: - print(f"⚠️ Error parsing TMDB NFO for dates: {e}") - - return None - - def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]: - """Decide movie dates based on configuration and available data""" - _log("DEBUG", f"_decide_movie_dates for {imdb_id}: should_query={should_query}, existing={existing}") - - if not should_query and existing: - _log("DEBUG", f"Using existing data without querying: dateadded={existing.get('dateadded')}, source={existing.get('source')}") - return existing["dateadded"], existing["source"], existing.get("released") - - # Query Radarr for movie info - radarr_movie = None - if should_query and self.radarr.api_key: - radarr_movie = self.radarr.movie_by_imdb(imdb_id) - - released = None - if radarr_movie: - released = self._parse_date_to_iso(radarr_movie.get("inCinemas")) - - # Try import history first if configured - if config.movie_priority == "import_then_digital": - import_date, import_source = None, None - if radarr_movie: - movie_id = radarr_movie.get("id") - if movie_id: - import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) - _log("INFO", f"Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}") - - # Check for special case: rename-first scenario (should prefer release dates) - if import_source == "radarr:db.prefer_release_dates": - _log("INFO", f"🎯 Movie {imdb_id} has rename-first history - skipping import, preferring release dates") - # Fall through to release date logic below - # Check if we got a real import date or just file date fallback - elif import_date and import_source != "radarr:db.file.dateAdded": - # Convert import date to local timezone for NFO files - local_import_date = convert_utc_to_local(import_date) - _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") - return local_import_date, import_source, released - - # Get digital release date for comparison/fallback - _log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date fallback...") - digital_date, digital_source = self._get_digital_release_date(imdb_id) - _log("INFO", f"Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}") - - # If we only have file date and release date exists, prefer it if reasonable and enabled - if import_date and import_source == "radarr:db.file.dateAdded" and digital_date and config.prefer_release_dates_over_file_dates: - # Compare dates - prefer release date if it's reasonable - if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id): - _log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date") - return digital_date, digital_source, released - else: - # Convert file date to local timezone for NFO files - local_file_date = convert_utc_to_local(import_date) - _log("INFO", f"✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable") - return local_file_date, import_source, released - - # Use whichever we have - if import_date: - # Convert import date to local timezone for NFO files - local_import_date = convert_utc_to_local(import_date) - _log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}") - return local_import_date, import_source, released - elif digital_date: - _log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}") - return digital_date, digital_source, released - else: - _log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks") - - # Try Radarr's own NFO premiered date as fallback - radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path) - if radarr_premiered: - _log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}") - return radarr_premiered, "radarr:nfo.premiered", released - - else: # digital_then_import - # Try digital release first - digital_date, digital_source = self._get_digital_release_date(imdb_id) - if digital_date: - return digital_date, digital_source, released - - # Fall back to import history - if radarr_movie: - movie_id = radarr_movie.get("id") - if movie_id: - import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback) - if import_date: - # Convert import date to local timezone for NFO files - local_import_date = convert_utc_to_local(import_date) - return local_import_date, import_source, released - - # Last resort: file mtime (if allowed) - if config.allow_file_date_fallback: - return self._get_file_mtime_date(movie_path) - else: - _log("INFO", f"No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation") - - # Log to failed movies debug file for troubleshooting - self._log_failed_movie(movie_path, imdb_id, "No import date, no release date, file date fallback disabled") - - return None, "no_valid_date_source", None - - def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]: - """Get release date from external sources using configured priority""" - _log("INFO", f"🔍 Calling external clients for {imdb_id}") - _log("INFO", f"Release date priority: {config.release_date_priority}") - _log("INFO", f"Smart validation enabled: {config.enable_smart_date_validation}") - - try: - release_result = self.external_clients.get_release_date_by_priority( - imdb_id, - config.release_date_priority, - enable_smart_validation=config.enable_smart_date_validation - ) - _log("INFO", f"External clients result for {imdb_id}: {release_result}") - - if release_result: - _log("INFO", f"✅ Got release date: {release_result[0]} from {release_result[1]}") - return release_result[0], release_result[1] - else: - _log("WARNING", f"❌ No release date found from external clients for {imdb_id}") - return None, "release:none" - except Exception as e: - _log("ERROR", f"External clients error for {imdb_id}: {e}") - return None, f"release:error:{str(e)}" - - def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]: - """Extract premiered date from Radarr's existing movie.nfo file""" - try: - nfo_path = movie_path / "movie.nfo" - if not nfo_path.exists(): - _log("DEBUG", f"No existing NFO file found at {nfo_path}") - return None - - nfo_content = nfo_path.read_text(encoding='utf-8') - - # Look for YYYY-MM-DD - import re - match = re.search(r'(\d{4}-\d{2}-\d{2})', nfo_content) - if match: - premiered_date = match.group(1) - # Convert to ISO format with timezone - iso_date = f"{premiered_date}T00:00:00+00:00" - _log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}") - return iso_date - else: - _log("DEBUG", f"No tag found in existing NFO") - return None - - except Exception as e: - _log("ERROR", f"Error reading Radarr NFO file: {e}") - return None - - def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None): - """Log movies that failed to get valid dates to a debug file""" - try: - log_dir = Path("logs") - log_dir.mkdir(exist_ok=True) - - failed_log_path = log_dir / "failed_movies.log" - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - log_entry = f"[{timestamp}] {movie_path.name} | IMDb: {imdb_id} | Reason: {reason}" - if available_countries: - log_entry += f" | Available Countries: {', '.join(available_countries)}" - log_entry += "\n" - - with open(failed_log_path, "a", encoding="utf-8") as f: - f.write(log_entry) - - _log("INFO", f"📝 Logged failed movie to {failed_log_path}: {movie_path.name}") - - except Exception as e: - _log("ERROR", f"Failed to write to failed movies log: {e}") - - def _get_file_mtime_date(self, movie_path: Path) -> Tuple[str, str, Optional[str]]: - """Get date from file modification time as last resort""" - video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") - newest_mtime = None - - for file_path in movie_path.iterdir(): - if file_path.is_file() and file_path.suffix.lower() in video_exts: - try: - mtime = file_path.stat().st_mtime - if newest_mtime is None or mtime > newest_mtime: - newest_mtime = mtime - except Exception: - continue - - if newest_mtime: - try: - # Use local timezone for file modification times - local_tz = _get_local_timezone() - iso_date = datetime.fromtimestamp(newest_mtime, tz=local_tz).isoformat(timespec="seconds") - return iso_date, "file:mtime", None - except Exception: - pass - - return "MANUAL_REVIEW_NEEDED", "manual_review_required", None - - def _should_prefer_release_over_file_date(self, release_date: str, release_source: str, theatrical_release: Optional[str], imdb_id: str) -> bool: - """ - Decide if release date should be preferred over file date - - Logic: - - For theatrical dates: Always prefer over file dates (they're authoritative) - - For physical dates: Usually prefer over file dates - - For digital dates: Prefer if reasonable (not decades before theatrical) - """ - try: - release_dt = datetime.fromisoformat(release_date.replace("Z", "+00:00")) - - # Always prefer theatrical and physical releases over file dates - if any(release_type in release_source for release_type in ["theatrical", "physical"]): - _log("INFO", f"Release date {release_date} ({release_source}) for {imdb_id}, preferring over file date") - return True - - # If we have theatrical release date, compare digital against it - if theatrical_release: - theatrical_dt = datetime.fromisoformat(theatrical_release.replace("Z", "+00:00")) - year_diff = release_dt.year - theatrical_dt.year - - # If digital is more than 10 years before theatrical, it's probably wrong - if year_diff < -10: - _log("INFO", f"Release date {release_date} is {abs(year_diff)} years before theatrical {theatrical_release} for {imdb_id}, using file date instead") - return False - - # If digital is within reasonable range (theatrical to +20 years), use it - if -2 <= year_diff <= 20: - _log("INFO", f"Release date {release_date} is reasonable for {imdb_id} (theatrical: {theatrical_release}), preferring over file date") - return True - - # If no theatrical date, use digital if it's not absurdly old - if release_dt.year >= 1990: # Reasonable minimum for digital releases - _log("INFO", f"Release date {release_date} seems reasonable for {imdb_id}, preferring over file date") - return True - - _log("INFO", f"Release date {release_date} seems too old for {imdb_id}, using file date instead") - return False - - except Exception as e: - _log("WARNING", f"Error comparing dates for {imdb_id}: {e}") - return False - - def _parse_date_to_iso(self, date_str: str) -> Optional[str]: - """Parse date string to ISO format""" - if not date_str: - return None - try: - if len(date_str) == 10 and date_str[4] == "-": - dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) - else: - dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) - return dt.isoformat(timespec="seconds") - except Exception: - return None - - -# --------------------------- -# Batching System -# --------------------------- - -class WebhookBatcher: - """Batches webhook events to avoid processing storms""" - - def __init__(self, nfo_manager=None): - self.pending: 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) - # NFO manager for comprehensive IMDb detection - self.nfo_manager = nfo_manager - - def add_webhook(self, key: str, webhook_data: Dict, media_type: str): - """Add webhook to batch queue""" - with self.lock: - if key in self.timers: - self.timers[key].cancel() - - webhook_data['media_type'] = media_type - self.pending[key] = webhook_data - _log("INFO", f"Batched {media_type} webhook for {key}") - _log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {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): - """Process a batched item""" - with self.lock: - if key in self.processing or key not in self.pending: - return - self.processing.add(key) - webhook_data = self.pending.pop(key) - self.timers.pop(key, None) - - try: - self.executor.submit(self._process_sync, key, webhook_data) - except Exception as e: - _log("ERROR", f"Error submitting processing for {key}: {e}") - with self.lock: - self.processing.discard(key) - - def _process_sync(self, key: str, webhook_data: Dict): - """Synchronous processing of webhook data with validation""" - try: - media_type = webhook_data.get('media_type') - path_str = webhook_data.get('path') - - _log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}") - - if not path_str: - _log("ERROR", f"No path found for {media_type} {key}") - return - - path_obj = Path(path_str) - if not path_obj.exists(): - _log("ERROR", f"BATCH PROCESSING FAILED: Path does not exist: {path_obj}") - _log("ERROR", f"This indicates a path mapping issue - webhook rejected to prevent wrong processing") - return - - # CRITICAL: Validate that the path contains the expected IMDb ID for movies - if media_type == 'movie': - expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key - - # Use comprehensive IMDb detection (directory, filenames, NFO files) - if self.nfo_manager: - detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj) - imdb_match = False - if detected_imdb: - # Compare with and without 'tt' prefix for flexibility - if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''): - imdb_match = True - - if not imdb_match: - _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}") - _log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}") - _log("ERROR", f"This prevents processing wrong movies due to batch corruption") - return - _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}") - else: - # Fallback to simple string search if nfo_manager not available - if expected_imdb not in path_str.lower(): - _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)") - _log("ERROR", f"This prevents processing wrong movies due to batch corruption") - return - _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)") - - # Process based on media type - if media_type == 'tv': - # Check processing mode for TV webhooks - processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode) - episodes_data = webhook_data.get('episodes', []) - - if processing_mode == 'targeted' and episodes_data: - _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") - tv_processor.process_webhook_episodes(path_obj, episodes_data) - else: - _log("INFO", f"Using series processing mode (fallback or configured)") - tv_processor.process_series(path_obj) - elif media_type == 'movie': - movie_processor.process_movie(path_obj, webhook_mode=True) - else: - _log("ERROR", f"Unknown media type: {media_type}") - - except Exception as e: - _log("ERROR", f"Error processing {media_type} {key}: {e}") - finally: - with self.lock: - self.processing.discard(key) - - def get_status(self) -> Dict: - """Get batch queue status""" - with self.lock: - return { - "pending_items": list(self.pending.keys()), - "processing_items": list(self.processing), - "pending_count": len(self.pending), - "processing_count": len(self.processing) - } - - -# --------------------------- -# FastAPI Application -# --------------------------- - -# Get version -try: - version = (Path(__file__).parent / "VERSION").read_text().strip() -except: - version = "0.1.0" - -# Check if running from dev branch (detect at runtime) -try: - # Try to read git branch from .git/HEAD - git_head_path = Path(__file__).parent / ".git" / "HEAD" - if git_head_path.exists(): - head_content = git_head_path.read_text().strip() - if "ref: refs/heads/dev" in head_content: - version = f"{version}-dev" - elif head_content.startswith("ref: refs/heads/"): - # Extract branch name for other branches - branch = head_content.split("refs/heads/")[-1] - if branch != "main": - version = f"{version}-{branch}" -except Exception: - # If git detection fails, that's fine - use base version - pass - -# Check for build source (only add -gitea for local Gitea builds) -build_source = os.environ.get("BUILD_SOURCE", "") -if build_source == "gitea": - if "gitea" not in version: # Don't double-add gitea suffix - version = f"{version}-gitea" - -app = FastAPI( - title="NFOGuard", - description="Webhook server for preserving media import dates", - version=version -) - -start_time = datetime.now(timezone.utc) - -# Initialize components -db = NFOGuardDatabase(config.db_path) -nfo_manager = NFOManager(config.manager_brand, config.debug) -path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper -tv_processor = TVProcessor(db, nfo_manager, path_mapper) -movie_processor = MovieProcessor(db, nfo_manager, path_mapper) -batcher = WebhookBatcher(nfo_manager) - -# --------------------------- -# Webhook Handlers -# --------------------------- - -async def _read_payload(request: Request) -> dict: - """Read webhook payload from request""" - content_type = (request.headers.get("content-type") or "").lower() - try: - if "application/json" in content_type: - 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 {} - -@app.post("/webhook/sonarr") -async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks): - """Handle Sonarr webhooks""" - try: - payload = await _read_payload(request) - if not payload: - raise HTTPException(status_code=422, detail="Empty Sonarr 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"} - - if not webhook.series: - return {"status": "ignored", "reason": "No series data"} - - series_info = webhook.series - series_title = series_info.get("title", "") - imdb_id = series_info.get("imdbId", "").replace("tt", "").strip() - if imdb_id: - imdb_id = f"tt{imdb_id}" - sonarr_path = series_info.get("path", "") - - if not imdb_id: - _log("ERROR", f"No IMDb ID for series: {series_title}") - return {"status": "error", "reason": "No IMDb ID"} - - # Find series path - series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path) - if not series_path: - _log("ERROR", f"Could not find series directory: {series_title} ({imdb_id})") - return {"status": "error", "reason": "Series directory not found"} - - # Add to batch queue with TV-prefixed key to avoid movie conflicts - tv_batch_key = f"tv:{imdb_id}" - webhook_dict = { - 'path': str(series_path), - 'series_info': series_info, - 'event_type': webhook.eventType, - 'episodes': webhook.episodes or [], # Include episode data for targeted processing - 'processing_mode': config.tv_webhook_processing_mode - } - batcher.add_webhook(tv_batch_key, webhook_dict, 'tv') - - return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"} - - except Exception as e: - _log("ERROR", f"Sonarr webhook error: {e}") - raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}") - -@app.post("/webhook/radarr") -async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): - """Handle Radarr webhooks""" - try: - payload = await _read_payload(request) - _log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}") - _log("DEBUG", f"Full Radarr webhook payload: {payload}") - - # Filter supported event types (same as Sonarr: Download, Upgrade, Rename) - event_type = payload.get('eventType', '') - if event_type not in ["Download", "Upgrade", "Rename"]: - return {"status": "ignored", "reason": f"Event type {event_type} not processed"} - - # Extract movie info - movie_data = payload.get("movie", {}) - if not movie_data: - _log("WARNING", "No movie data in Radarr webhook") - return {"status": "error", "message": "No movie data"} - - # Get IMDb ID for batching key - imdb_id = movie_data.get("imdbId", "").lower() - if not imdb_id: - _log("WARNING", "No IMDb ID in Radarr webhook movie data") - return {"status": "error", "message": "No IMDb ID"} - - # Get movie path and map it - movie_path = movie_data.get("folderPath") or movie_data.get("path", "") - if not movie_path: - _log("ERROR", "No movie path in Radarr webhook") - return {"status": "error", "message": "No movie path provided"} - - # Map the path to container path - container_path = path_mapper.radarr_path_to_container_path(movie_path) - _log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}") - - # CRITICAL: Verify the mapped path actually exists - from pathlib import Path - if not Path(container_path).exists(): - _log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}") - _log("ERROR", f"This prevents processing wrong movies due to path mapping issues") - return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"} - - # Verify the path contains the expected IMDb ID - if imdb_id not in container_path.lower(): - _log("WARNING", f"IMDb ID {imdb_id} not found in container path {container_path}") - - # Create movie-specific webhook data with proper path validation - movie_webhook_data = { - 'path': container_path, # Use verified container path - 'movie_info': movie_data, - 'event_type': payload.get('eventType'), - 'original_payload': payload - } - - # Add to batch queue with movie-prefixed key to avoid TV conflicts - movie_batch_key = f"movie:{imdb_id}" - _log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}") - batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie") - - return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"} - - except Exception as e: - _log("ERROR", f"Radarr webhook error: {e}") - return {"status": "error", "message": str(e)} - -# --------------------------- -# API Endpoints -# --------------------------- - -@app.get("/health") -async def health() -> HealthResponse: - """Health check endpoint with Radarr database status""" - uptime = datetime.now(timezone.utc) - start_time - - # Check NFOGuard database - try: - with db.get_connection() as conn: - conn.execute("SELECT 1").fetchone() - db_status = "healthy" - except Exception as e: - db_status = f"error: {e}" - - # Check Radarr database if available - radarr_db_health = None - overall_status = "healthy" if db_status == "healthy" else "degraded" - - # Get Radarr client with database access from movie processor - try: - if hasattr(movie_processor, 'radarr') and movie_processor.radarr: - radarr_client = movie_processor.radarr - if hasattr(radarr_client, 'db_client') and radarr_client.db_client: - try: - radarr_db_health = radarr_client.db_client.health_check() - if radarr_db_health["status"] != "healthy": - overall_status = "degraded" - except Exception as e: - radarr_db_health = { - "status": "error", - "error": str(e), - "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds") - } - overall_status = "degraded" - except Exception as e: - # If movie processor isn't available, skip database health check - _log("DEBUG", f"Skipping Radarr database health check: {e}") - - return HealthResponse( - status=overall_status, - version=version, - uptime=str(uptime), - database_status=db_status, - radarr_database=radarr_db_health - ) - -@app.get("/stats") -async def get_stats(): - """Get database statistics""" - try: - return db.get_stats() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/batch/status") -async def batch_status(): - """Get batch queue 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 (os.environ.get("RADARR_URL") and os.environ.get("RADARR_API_KEY")): - return { - "error": "Radarr not configured", - "imdb_id": imdb_id, - "radarr_configured": False - } - - # Create Radarr client - from clients.radarr_client import RadarrClient - radarr_client = RadarrClient( - os.environ.get("RADARR_URL"), - os.environ.get("RADARR_API_KEY") - ) - - # Look up movie - movie_obj = radarr_client.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})") - - # Test the FULL movie processing pipeline (not just database lookup) - _log("INFO", f"=== TESTING FULL MOVIE PROCESSING PIPELINE ===") - - # Create a dummy path for testing the decision logic - from pathlib import Path - dummy_path = Path("/tmp/test") - - try: - # Use the global movie processor instance to test full decision logic - global movie_processor - if movie_processor: - # First check external clients configuration - _log("INFO", f"=== CHECKING EXTERNAL CLIENTS CONFIG ===") - try: - tmdb_key = os.environ.get("TMDB_API_KEY", "") - _log("INFO", f"TMDB API Key configured: {'✅ YES' if tmdb_key else '❌ NO'}") - if tmdb_key: - _log("INFO", f"TMDB API Key length: {len(tmdb_key)} chars") - - # Check if external clients exist - external_clients_available = hasattr(movie_processor, 'external_clients') and movie_processor.external_clients - _log("INFO", f"External clients initialized: {'✅ YES' if external_clients_available else '❌ NO'}") - - except Exception as e: - _log("ERROR", f"Error checking external clients config: {e}") - - # Test the full decision logic (including TMDB fallback) - final_date, final_source, released = movie_processor._decide_movie_dates( - imdb_id, dummy_path, should_query=True, existing=None - ) - - _log("INFO", f"=== FULL PIPELINE RESULT ===") - _log("INFO", f"Final date: {final_date}") - _log("INFO", f"Final source: {final_source}") - _log("INFO", f"Released (theater): {released}") - - return { - "imdb_id": imdb_id, - "radarr_configured": True, - "movie_found": True, - "movie_title": movie_title, - "movie_id": movie_id, - "full_pipeline_test": { - "final_date": final_date, - "final_source": final_source, - "theater_release": released, - "decision_logic": "✅ TESTED FULL PIPELINE INCLUDING TMDB FALLBACK" - }, - "database_only_test": { - "radarr_db_result": radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True), - "note": "This is just the database part - fallback happens in full pipeline" - }, - "debug_info": { - "radarr_url": os.environ.get("RADARR_URL"), - "movie_digital_release": movie_obj.get("digitalRelease"), - "movie_in_cinemas": movie_obj.get("inCinemas"), - "movie_physical_release": movie_obj.get("physicalRelease"), - "movie_folder_path": movie_obj.get("folderPath") - } - } - else: - _log("ERROR", "Movie processor not available - testing database only") - # Fallback to database-only testing - import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) - return { - "error": "Movie processor not available - only database test performed", - "imdb_id": imdb_id, - "radarr_configured": True, - "movie_found": True, - "movie_title": movie_title, - "movie_id": movie_id, - "detected_import_date": import_date, - "import_source": source, - "debug_info": { - "note": "FULL PIPELINE TEST FAILED - movie processor not initialized" - } - } - - except Exception as pipeline_error: - _log("ERROR", f"Full pipeline test failed: {pipeline_error}") - # Fallback to database-only testing - import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) - return { - "pipeline_error": str(pipeline_error), - "imdb_id": imdb_id, - "radarr_configured": True, - "movie_found": True, - "movie_title": movie_title, - "movie_id": movie_id, - "detected_import_date": import_date, - "import_source": source, - "debug_info": { - "note": "FULL PIPELINE TEST FAILED - showing database-only result" - } - } - - except Exception as e: - _log("ERROR", f"Debug endpoint error for {imdb_id}: {e}") - return { - "error": str(e), - "imdb_id": imdb_id, - "success": False - } - -@app.get("/debug/movie/{imdb_id}/history") -async def debug_movie_history(imdb_id: str): - """Detailed history analysis for a movie""" - try: - if not imdb_id.startswith("tt"): - imdb_id = f"tt{imdb_id}" - - _log("INFO", f"=== DETAILED HISTORY ANALYSIS: {imdb_id} ===") - - # Use database-only mode for consistency - if not movie_processor.radarr.db_client: - return {"error": "Radarr database not configured"} - - radarr_client = movie_processor.radarr - - # Look up movie - movie_obj = radarr_client.movie_by_imdb(imdb_id) - if not movie_obj: - return {"error": f"Movie not found for {imdb_id}"} - - movie_id = movie_obj.get("id") - movie_title = movie_obj.get("title") - - # Get history from database instead of API - if not radarr_client.db_client: - return {"error": "Database-only mode required"} - - # TODO: Implement database-only history retrieval - return { - "error": "History endpoint temporarily disabled - use /debug/movie/{imdb_id}/priority for date analysis", - "imdb_id": imdb_id, - "movie_id": movie_id, - "movie_title": movie_title, - "note": "This endpoint needs database-only implementation to avoid showing wrong movie events" - } - - # Analyze each event - analyzed_events = [] - for event in all_history: - event_type = event.get("eventType", "") - date_str = event.get("date", "") - event_data = event.get("data", {}) - - # Get source path info - source_path = ( - event_data.get('droppedPath', '') or - event_data.get('sourcePath', '') or - event_data.get('path', '') or - event_data.get('sourceTitle', '') - ) - - # Analyze if this is a real import - is_real, reason, date_iso = radarr_client._analyze_event_for_import(event) - - analyzed_events.append({ - "event_type": event_type, - "date": date_str, - "source_path": source_path, - "is_real_import": is_real, - "analysis_reason": reason, - "parsed_date": date_iso, - "full_data": event_data - }) - - # Find what our algorithm would pick - DATABASE ONLY - picked_date, _ = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True) - - return { - "imdb_id": imdb_id, - "movie_title": movie_title, - "movie_id": movie_id, - "total_history_events": len(all_history), - "our_algorithm_picked": picked_date, - "all_events": analyzed_events, - "expected_july_date": "2025-07-07" in (picked_date or "") - } - - except Exception as e: - _log("ERROR", f"History debug error for {imdb_id}: {e}") - return {"error": str(e)} - -@app.post("/manual/scan") -async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"): - """Manual scan endpoint""" - if scan_type not in ["both", "tv", "movies"]: - raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'") - - async def run_scan(): - paths_to_scan = [] - if path: - paths_to_scan = [Path(path)] - else: - if scan_type in ["both", "tv"]: - paths_to_scan.extend(config.tv_paths) - if scan_type in ["both", "movies"]: - paths_to_scan.extend(config.movie_paths) - - for scan_path in paths_to_scan: - if not scan_path.exists(): - continue - - if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path): - # Handle specific season/episode path - if path and scan_path.name.lower().startswith('season'): - # Single season processing - series_path = scan_path.parent - if nfo_manager.parse_imdb_from_path(series_path): - _log("INFO", f"Processing single season: {scan_path}") - try: - tv_processor.process_season(series_path, scan_path) - except Exception as e: - _log("ERROR", f"Failed processing season {scan_path}: {e}") - elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'): - # Single episode processing - season_path = scan_path.parent - series_path = season_path.parent - if nfo_manager.parse_imdb_from_path(series_path): - _log("INFO", f"Processing single episode: {scan_path}") - try: - tv_processor.process_episode_file(series_path, season_path, scan_path) - except Exception as e: - _log("ERROR", f"Failed processing episode {scan_path}: {e}") - else: - # Check if this path itself is a series (has IMDb ID in the directory name) - if nfo_manager.parse_imdb_from_path(scan_path): - try: - tv_processor.process_series(scan_path) - except Exception as e: - _log("ERROR", f"Failed processing TV series {scan_path}: {e}") - else: - # Full series processing - scan subdirectories - for item in scan_path.iterdir(): - if (item.is_dir() and - not item.name.lower().startswith('season') and - not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and - nfo_manager.parse_imdb_from_path(item)): - try: - tv_processor.process_series(item) - except Exception as e: - _log("ERROR", f"Failed processing TV series {item}: {e}") - - if scan_type in ["both", "movies"] and scan_path in config.movie_paths: - _log("INFO", f"Scanning movies in: {scan_path}") - movie_count = 0 - for item in scan_path.iterdir(): - if item.is_dir() and nfo_manager.find_movie_imdb_id(item): - movie_count += 1 - _log("INFO", f"Processing movie: {item.name}") - try: - movie_processor.process_movie(item) - except Exception as e: - _log("ERROR", f"Failed processing movie {item}: {e}") - _log("INFO", f"Completed movie scan: {movie_count} movies processed in {scan_path}") - - background_tasks.add_task(run_scan) - return {"status": "started", "message": f"Manual {scan_type} scan started"} - -@app.post("/tv/scan-season") -async def scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest): - """Scan a specific TV season - URL-safe endpoint""" - try: - series_dir = Path(request.series_path) - season_dir = series_dir / request.season_name - - if not series_dir.exists(): - raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}") - if not season_dir.exists(): - raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}") - - imdb_id = nfo_manager.parse_imdb_from_path(series_dir) - if not imdb_id: - raise HTTPException(status_code=400, detail="No IMDb ID found in series path") - - async def process_season(): - _log("INFO", f"Processing TV season: {season_dir}") - try: - tv_processor.process_season(series_dir, season_dir) - except Exception as e: - _log("ERROR", f"Failed processing season {season_dir}: {e}") - - background_tasks.add_task(process_season) - return {"status": "started", "message": f"Season scan started for {request.season_name}"} - - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/tv/scan-episode") -async def scan_tv_episode(background_tasks: BackgroundTasks, request: TVEpisodeRequest): - """Scan a specific TV episode - URL-safe endpoint""" - try: - series_dir = Path(request.series_path) - season_dir = series_dir / request.season_name - episode_file = season_dir / request.episode_name - - if not series_dir.exists(): - raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}") - if not episode_file.exists(): - raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}") - - imdb_id = nfo_manager.parse_imdb_from_path(series_dir) - if not imdb_id: - raise HTTPException(status_code=400, detail="No IMDb ID found in series path") - - async def process_episode(): - _log("INFO", f"Processing TV episode: {episode_file}") - try: - tv_processor.process_episode_file(series_dir, season_dir, episode_file) - except Exception as e: - _log("ERROR", f"Failed processing episode {episode_file}: {e}") - - background_tasks.add_task(process_episode) - return {"status": "started", "message": f"Episode scan started for {request.episode_name}"} - - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.post("/test/bulk-update") -async def test_bulk_update(): - """Test bulk update functionality without modifying data""" - try: - from clients.radarr_db_client import RadarrDbClient - - # Test Radarr database - radarr_db = RadarrDbClient.from_env() - if not radarr_db: - return {"status": "error", "message": "Radarr database connection failed"} - - # Test query execution - query = 'SELECT COUNT(*) FROM "Movies" m JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" WHERE mm."ImdbId" IS NOT NULL' - with radarr_db._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(query) - movie_count = cursor.fetchone()[0] - - return { - "status": "success", - "message": "Bulk update test passed", - "movies_with_imdb": movie_count, - "database_type": radarr_db.db_type - } - except Exception as e: - return {"status": "error", "message": f"Bulk update test failed: {e}"} - -@app.post("/test/movie-scan") -async def test_movie_scan(): - """Test movie directory scanning logic""" - try: - results = [] - for path in config.movie_paths: - path_result = { - "path": str(path), - "exists": path.exists(), - "movies_found": 0 - } - - if path.exists(): - for item in path.iterdir(): - if item.is_dir() and nfo_manager.find_movie_imdb_id(item): - path_result["movies_found"] += 1 - - results.append(path_result) - - total_movies = sum(r["movies_found"] for r in results) - return { - "status": "success", - "message": f"Movie scan test found {total_movies} movies", - "path_results": results - } - except Exception as e: - return {"status": "error", "message": f"Movie scan test failed: {e}"} - -@app.post("/bulk/update") -async def trigger_bulk_update(background_tasks: BackgroundTasks): - """Trigger bulk update of all movies""" - async def run_bulk_update(): - try: - from bulk_update_movies import bulk_update_all_movies - success = bulk_update_all_movies() - _log("INFO", f"Bulk update completed: {'success' if success else 'failed'}") - except Exception as e: - _log("ERROR", f"Bulk update error: {e}") - - background_tasks.add_task(run_bulk_update) - return {"status": "started", "message": "Bulk update started"} - -@app.get("/debug/movie/{imdb_id}/priority") -async def debug_movie_priority_logic(imdb_id: str): - """Debug endpoint showing how MOVIE_PRIORITY affects date selection""" - try: - if not imdb_id.startswith("tt"): - imdb_id = f"tt{imdb_id}" - - result = { - "imdb_id": imdb_id, - "movie_priority": config.movie_priority, - "release_date_priority": config.release_date_priority, - "priority_explanation": "", - "date_sources": {}, - "selected_date": None, - "selected_source": None - } - - # Get Radarr import date - if movie_processor.radarr.api_key: - radarr_movie = movie_processor.radarr.movie_by_imdb(imdb_id) - if radarr_movie: - movie_id = radarr_movie.get("id") - if movie_id: - import_date, import_source = movie_processor.radarr.get_movie_import_date(movie_id) - if import_date: - result["date_sources"]["radarr_import"] = { - "date": import_date, - "source": import_source - } - - # Get digital release dates with detailed logging - digital_date, digital_source = movie_processor._get_digital_release_date(imdb_id) - if digital_date: - result["date_sources"]["digital_release"] = { - "date": digital_date, - "source": digital_source - } - else: - # Add debug info about why digital date wasn't found - candidates = movie_processor.external_clients.get_digital_release_candidates(imdb_id) - result["date_sources"]["digital_release_debug"] = { - "candidates_found": len(candidates), - "candidates": candidates[:3] if candidates else [], # Show first 3 - "reason": digital_source if digital_source else "no_digital_dates_found" - } - - # Show priority logic - if config.movie_priority == "import_then_digital": - priority_list = " → ".join(config.release_date_priority) - result["priority_explanation"] = f"1st: Radarr import history, 2nd: Release dates ({priority_list}), 3rd: file mtime. Note: If import is only file date, prefer reasonable release dates." - - radarr_import = result["date_sources"].get("radarr_import") - digital_release = result["date_sources"].get("digital_release") - - # Check for file date fallback logic - if radarr_import and radarr_import["source"] == "radarr:db.file.dateAdded" and digital_release: - # Test the smart logic - would_prefer_digital = movie_processor._should_prefer_release_over_file_date( - digital_release["date"], - digital_release["source"], - None, # We don't have theatrical date in this debug context - imdb_id - ) - result["file_date_detected"] = True - result["would_prefer_digital"] = would_prefer_digital - - if would_prefer_digital: - result["selected_date"] = digital_release["date"] - result["selected_source"] = digital_release["source"] + " (preferred over file date)" - else: - result["selected_date"] = radarr_import["date"] - result["selected_source"] = radarr_import["source"] + " (digital too old)" - elif radarr_import and radarr_import["source"] != "radarr:db.file.dateAdded": - result["selected_date"] = radarr_import["date"] - result["selected_source"] = radarr_import["source"] - elif digital_release: - result["selected_date"] = digital_release["date"] - result["selected_source"] = digital_release["source"] - else: # digital_then_import - result["priority_explanation"] = "1st: TMDB/OMDb digital release, 2nd: Radarr import history, 3rd: file mtime" - if result["date_sources"].get("digital_release"): - result["selected_date"] = result["date_sources"]["digital_release"]["date"] - result["selected_source"] = result["date_sources"]["digital_release"]["source"] - elif result["date_sources"].get("radarr_import"): - result["selected_date"] = result["date_sources"]["radarr_import"]["date"] - result["selected_source"] = result["date_sources"]["radarr_import"]["source"] - - # Show external API status - result["external_apis"] = { - "tmdb_enabled": movie_processor.external_clients.tmdb.enabled, - "omdb_enabled": movie_processor.external_clients.omdb.enabled, - "jellyseerr_enabled": movie_processor.external_clients.jellyseerr.enabled - } - - return result - - except Exception as e: - return {"error": str(e), "imdb_id": imdb_id} - -@app.get("/debug/tmdb/{imdb_id}") -async def debug_tmdb_lookup(imdb_id: str): - """Debug TMDB API lookup for a specific movie""" - try: - if not imdb_id.startswith("tt"): - imdb_id = f"tt{imdb_id}" - - result = { - "imdb_id": imdb_id, - "tmdb_api_enabled": movie_processor.external_clients.tmdb.enabled, - "tmdb_api_key_configured": bool(movie_processor.external_clients.tmdb.api_key), - "steps": {} - } - - if not movie_processor.external_clients.tmdb.enabled: - result["error"] = "TMDB API not enabled - check TMDB_API_KEY environment variable" - return result - - # Step 1: Find movie by IMDb ID - _log("INFO", f"TMDB Debug: Looking up {imdb_id}") - tmdb_movie = movie_processor.external_clients.tmdb.find_by_imdb(imdb_id) - result["steps"]["1_find_by_imdb"] = { - "found": bool(tmdb_movie), - "tmdb_movie": tmdb_movie if tmdb_movie else None - } - - if not tmdb_movie: - result["error"] = f"Movie {imdb_id} not found in TMDB" - return result - - tmdb_id = tmdb_movie.get("id") - result["tmdb_id"] = tmdb_id - - # Step 2: Get release dates - if tmdb_id: - _log("INFO", f"TMDB Debug: Getting release dates for TMDB ID {tmdb_id}") - release_dates_result = movie_processor.external_clients.tmdb._get(f"/movie/{tmdb_id}/release_dates") - result["steps"]["2_release_dates"] = { - "raw_response": release_dates_result, - "has_results": bool(release_dates_result and release_dates_result.get("results")) - } - - # Step 3: Look for US digital releases - if release_dates_result and release_dates_result.get("results"): - us_releases = [] - for country_data in release_dates_result["results"]: - if country_data.get("iso_3166_1") == "US": - us_releases = country_data.get("release_dates", []) - break - - result["steps"]["3_us_releases"] = { - "found_us_data": bool(us_releases), - "us_releases": us_releases - } - - # Step 4: Look for digital releases (type 4) - digital_releases = [r for r in us_releases if r.get("type") == 4] - result["steps"]["4_digital_releases"] = { - "digital_count": len(digital_releases), - "digital_releases": digital_releases - } - - # Step 5: Test the full digital release function - digital_date = movie_processor.external_clients.tmdb.get_digital_release_date(imdb_id) - result["steps"]["5_final_result"] = { - "digital_date": digital_date, - "success": bool(digital_date) - } - - return result - - except Exception as e: - return {"error": str(e), "imdb_id": imdb_id, "traceback": str(e)} - -# --------------------------- -# Main -# --------------------------- - -def signal_handler(signum, frame): - """Handle shutdown signals gracefully""" - _log("INFO", f"Received signal {signum}, shutting down gracefully...") - sys.exit(0) - -if __name__ == "__main__": - # Register signal handlers for graceful shutdown - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGINT, signal_handler) - - _log("INFO", "Starting NFOGuard") - _log("INFO", f"Version: {version}") - _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: {config.db_path}") - _log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}") - _log("INFO", f"Movie priority: {config.movie_priority}") - - try: - uvicorn.run( - app, - host="0.0.0.0", - port=int(os.environ.get("PORT", "8080")), - reload=False - ) - except KeyboardInterrupt: - _log("INFO", "NFOGuard stopped by user") - except Exception as e: - _log("ERROR", f"NFOGuard crashed: {e}") - sys.exit(1) \ No newline at end of file diff --git a/processors/movie_processor.py b/processors/movie_processor.py index 326a354..9156061 100644 --- a/processors/movie_processor.py +++ b/processors/movie_processor.py @@ -166,10 +166,14 @@ class MovieProcessor: dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released") # Create NFO with existing data + print(f"🔍 TIER2 - config.manage_nfo: {config.manage_nfo}") if config.manage_nfo: + print(f"🔍 TIER2 - Calling create_movie_nfo with dateadded: {dateadded}") self.nfo_manager.create_movie_nfo( movie_path, imdb_id, dateadded, released, source, config.lock_metadata ) + else: + print(f"❌ TIER2 - manage_nfo is disabled, skipping NFO creation") # Update file mtimes if enabled if config.fix_dir_mtimes and dateadded: @@ -225,10 +229,14 @@ class MovieProcessor: _log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})") # Create NFO regardless of date availability (preserves existing metadata) + print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}") if config.manage_nfo: + print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}") self.nfo_manager.create_movie_nfo( movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata ) + else: + print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation") # Skip remaining processing if no valid date found and file dates disabled if final_dateadded is None: