419 lines
19 KiB
Python
419 lines
19 KiB
Python
#!/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 core.fs_cache import fs_cache, extract_imdb_from_path
|
|
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'<dateadded>([^<]+)</dateadded>', content)
|
|
if dateadded_match:
|
|
dateadded = dateadded_match.group(1).strip()
|
|
|
|
# Extract premiered (release date)
|
|
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', 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 |