""" Movie Processor for NFOGuard Handles movie processing and metadata management """ import os import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Optional, Dict, List, Tuple from datetime import datetime, timezone from zoneinfo import ZoneInfo 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.external_clients import ExternalClientManager from config.settings import config from utils.logging import _log from utils.file_utils import find_media_path_by_imdb_and_title 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: # Final fallback to UTC return timezone.utc 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 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 using unified file utilities""" return find_media_path_by_imdb_and_title( title=movie_title, imdb_id=imdb_id, search_paths=config.movie_paths, webhook_path=radarr_path, path_mapper=self.path_mapper ) 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 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