#!/usr/bin/env python3 """ Movie processing logic for NFOGuard """ import os import glob from pathlib import Path from typing import Optional, Dict, Any, List from datetime import datetime, timezone # Import core components from core.database import NFOGuardDatabase from core.nfo_manager import NFOManager from core.path_mapper import PathMapper from core.logging import _log from clients.radarr_client import RadarrClient from clients.external_clients import ExternalClientManager from config.settings import config class MovieProcessor: """Handles movie processing and validation""" 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 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-enhanced]") return # TIER 2: Check database for existing movie data existing_movie = self.db.get_movie_dates(imdb_id) if existing_movie and existing_movie.get("dateadded") and existing_movie.get("source") != "no_valid_date_source": _log("INFO", f"✅ Using complete database data: {existing_movie['dateadded']} (source: {existing_movie['source']})") # Still create NFO and update files but skip API queries dateadded = existing_movie["dateadded"] source = existing_movie["source"] released = existing_movie.get("released") if config.manage_nfo and dateadded: self.nfo_manager.create_movie_nfo( movie_path, imdb_id, dateadded, released, source, config.lock_metadata ) 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 # TIER 3: Full processing with API calls (slowest) _log("DEBUG", f"Movie requires full processing - querying external APIs") # Get movie dates from various sources movie_dates = self.get_movie_dates(imdb_id, movie_path, webhook_mode=webhook_mode, fallback_to_tmdb=(not is_tmdb_fallback)) dateadded = movie_dates.get("dateadded") released = movie_dates.get("released") source = movie_dates.get("source", "no_valid_date_source") # Create NFO if config.manage_nfo and dateadded: self.nfo_manager.create_movie_nfo( movie_path, imdb_id, dateadded, released, source, config.lock_metadata ) # Update file mtimes 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}) [full-processing]") def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict]: """Extract dates from existing TMDB NFO file""" if not nfo_path.exists(): return None try: with open(nfo_path, 'r', encoding='utf-8') as f: content = f.read() # Look for dateadded and premiered elements dateadded = None released = None # Extract dateadded import re dateadded_match = re.search(r'([^<]+)', content) if dateadded_match: dateadded = dateadded_match.group(1).strip() # Extract premiered (release date) premiered_match = re.search(r'([^<]+)', content) if premiered_match: released = premiered_match.group(1).strip() if dateadded: return { "dateadded": dateadded, "released": released, "source": "tmdb:nfo.dateadded" } except Exception as e: _log("DEBUG", f"Could not extract dates from TMDB NFO: {e}") return None def get_movie_dates(self, imdb_id: str, movie_path: Path = None, webhook_mode: bool = False, fallback_to_tmdb: bool = True) -> Dict[str, Any]: """Get movie dates from various sources with priority system""" # Initialize result result = { "dateadded": None, "released": None, "source": "no_valid_date_source" } # Check if this is a TMDB ID if imdb_id.startswith("tmdb-"): return self._get_tmdb_movie_dates(imdb_id, movie_path) # Priority 1: Radarr import history (most accurate for dateadded) if self.radarr.enabled and config.movie_priority >= 1: try: movie_data = self.radarr.get_movie_by_imdb(imdb_id) if movie_data: # Get import history for accurate dateadded movie_id = movie_data.get("id") if movie_id: import_history = self.radarr.get_movie_import_history(movie_id) if import_history: result["dateadded"] = self._parse_date_to_iso(import_history) result["source"] = "radarr:history.import" _log("INFO", f"Found Radarr import date: {result['dateadded']}") # Get release date from Radarr release_date = movie_data.get("digitalRelease") or movie_data.get("physicalRelease") or movie_data.get("inCinemas") if release_date: result["released"] = self._parse_date_to_iso(release_date) # If we have dateadded from import, we're done if result["dateadded"]: return result # Fallback to using dateadded from Radarr API radarr_dateadded = movie_data.get("added") if radarr_dateadded: result["dateadded"] = self._parse_date_to_iso(radarr_dateadded) result["source"] = "radarr:movie.added" return result except Exception as e: _log("DEBUG", f"Radarr query failed for {imdb_id}: {e}") # Priority 2: External APIs (TMDB, OMDb, etc.) for release date if config.movie_priority >= 2 and fallback_to_tmdb: try: # Try TMDB if self.external_clients.tmdb.enabled: tmdb_data = self.external_clients.tmdb.find_by_imdb(imdb_id) if tmdb_data: release_date = tmdb_data.get("release_date") if release_date: released_iso = self._parse_date_to_iso(release_date) result["released"] = released_iso # Use release date as dateadded fallback if not result["dateadded"]: result["dateadded"] = released_iso result["source"] = "tmdb:release_date" return result # Try OMDb as additional fallback if self.external_clients.omdb.enabled: omdb_data = self.external_clients.omdb.get_by_imdb(imdb_id) if omdb_data and omdb_data.get("Released"): released_date = self._parse_omdb_date(omdb_data["Released"]) if released_date: result["released"] = released_date if not result["dateadded"]: result["dateadded"] = released_date result["source"] = "omdb:released" return result except Exception as e: _log("DEBUG", f"External API query failed for {imdb_id}: {e}") # Priority 3: File system dates as absolute fallback if not result["dateadded"] and movie_path and config.movie_priority >= 3: try: # Use directory creation time as last resort if movie_path.exists(): dir_stat = movie_path.stat() # Use the earliest of creation or modification time earliest_time = min(dir_stat.st_ctime, dir_stat.st_mtime) fs_date = datetime.fromtimestamp(earliest_time, tz=timezone.utc) result["dateadded"] = fs_date.isoformat(timespec="seconds") result["source"] = "filesystem:dir.ctime" _log("DEBUG", f"Using filesystem date as fallback: {result['dateadded']}") return result except Exception as e: _log("DEBUG", f"Filesystem date extraction failed: {e}") return result def _get_tmdb_movie_dates(self, tmdb_id: str, movie_path: Path = None) -> Dict[str, Any]: """Get movie dates for TMDB-only movies""" result = { "dateadded": None, "released": None, "source": "no_valid_date_source" } # Extract TMDB ID from the string (format: "tmdb-12345") try: tmdb_numeric_id = tmdb_id.replace("tmdb-", "") if self.external_clients.tmdb.enabled: tmdb_data = self.external_clients.tmdb.get_movie_details(tmdb_numeric_id) if tmdb_data: release_date = tmdb_data.get("release_date") if release_date: released_iso = self._parse_date_to_iso(release_date) result["released"] = released_iso result["dateadded"] = released_iso # Use release date as dateadded result["source"] = "tmdb:release_date" return result except Exception as e: _log("DEBUG", f"TMDB query failed for {tmdb_id}: {e}") # Fallback to filesystem date for TMDB movies if movie_path and movie_path.exists(): try: dir_stat = movie_path.stat() earliest_time = min(dir_stat.st_ctime, dir_stat.st_mtime) fs_date = datetime.fromtimestamp(earliest_time, tz=timezone.utc) result["dateadded"] = fs_date.isoformat(timespec="seconds") result["source"] = "filesystem:dir.ctime" except Exception as e: _log("DEBUG", f"Filesystem date extraction failed: {e}") return result def _parse_date_to_iso(self, date_str: str) -> Optional[str]: """Parse date string to ISO format""" if not date_str: return None try: # Handle different date formats if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) else: # ISO format with timezone dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc) return dt.isoformat(timespec="seconds") except Exception: return None def _parse_omdb_date(self, date_str: str) -> Optional[str]: """Parse OMDb date format (e.g., '25 Dec 2020')""" if not date_str or date_str == "N/A": return None try: # Parse OMDb date format: "25 Dec 2020" dt = datetime.strptime(date_str, "%d %b %Y").replace(tzinfo=timezone.utc) return dt.isoformat(timespec="seconds") except Exception: return None def validate_batch_movies(self, movie_paths: List[Path]) -> Dict[str, Any]: """Validate and process a batch of movies""" results = { "processed": 0, "errors": [], "skipped": 0 } _log("INFO", f"Starting batch processing of {len(movie_paths)} movies") for i, movie_path in enumerate(movie_paths, 1): try: _log("INFO", f"Processing movie {i}/{len(movie_paths)}: {movie_path.name}") # Check if directory exists and has video files if not movie_path.exists() or not movie_path.is_dir(): results["errors"].append(f"Path does not exist or is not a directory: {movie_path}") continue # Check for IMDb ID imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path) if not imdb_id: results["skipped"] += 1 _log("WARNING", f"Skipping {movie_path.name}: No IMDb ID found") continue # Process the movie self.process_movie(movie_path) results["processed"] += 1 except Exception as e: error_msg = f"Error processing {movie_path}: {str(e)}" results["errors"].append(error_msg) _log("ERROR", error_msg) continue _log("INFO", f"Batch processing complete: {results['processed']} processed, " f"{results['skipped']} skipped, {len(results['errors'])} errors") return results