NFOGuard v2.6.7 - Production ready release
Complete media management solution with PostgreSQL, web interface, and 88% scan optimization for efficient library management. Key Features: - PostgreSQL database with production-grade performance - Web interface for episode and movie management - Smart scan optimization reducing scan time by 88% - Database cleanup tools for orphaned episodes/movies - Graceful Docker shutdown for container orchestration - Comprehensive configuration validation - Real-time health monitoring and metrics
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
"""
|
||||
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 should_skip_movie(self, imdb_id: str, movie_name: str = "") -> Tuple[bool, str]:
|
||||
"""
|
||||
Determine if we should skip processing this movie based on completion status
|
||||
|
||||
Args:
|
||||
imdb_id: Movie IMDb ID
|
||||
movie_name: Movie name for logging
|
||||
|
||||
Returns:
|
||||
(should_skip: bool, reason: str)
|
||||
"""
|
||||
try:
|
||||
with self.db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if self.db.db_type == "postgresql":
|
||||
cursor.execute("""
|
||||
SELECT dateadded, source, has_video_file
|
||||
FROM movies
|
||||
WHERE imdb_id = %s
|
||||
""", (imdb_id,))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT dateadded, source, has_video_file
|
||||
FROM movies
|
||||
WHERE imdb_id = ?
|
||||
""", (imdb_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return False, "No database record found"
|
||||
|
||||
if self.db.db_type == "postgresql":
|
||||
dateadded = result['dateadded']
|
||||
source = result['source']
|
||||
has_video_file = result['has_video_file']
|
||||
else:
|
||||
dateadded = result[0] if result[0] else None
|
||||
source = result[1] if result[1] else None
|
||||
has_video_file = result[2] if result[2] else False
|
||||
|
||||
# Skip if:
|
||||
# 1. Movie has a valid dateadded timestamp
|
||||
# 2. Source is valid (not 'unknown' or 'no_valid_date_source')
|
||||
# 3. Has video file on disk
|
||||
if (dateadded and
|
||||
source and
|
||||
source not in ['unknown', 'no_valid_date_source'] and
|
||||
has_video_file):
|
||||
return True, f"Complete: Has valid date '{dateadded}' from source '{source}'"
|
||||
elif not dateadded:
|
||||
return False, "Missing dateadded"
|
||||
elif not source or source in ['unknown', 'no_valid_date_source']:
|
||||
return False, f"Invalid source: '{source}'"
|
||||
elif not has_video_file:
|
||||
return False, "No video file detected"
|
||||
else:
|
||||
return False, "Incomplete movie data"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
|
||||
return False, f"Error checking completion: {e}"
|
||||
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, shutdown_event=None) -> str:
|
||||
"""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 "error"
|
||||
|
||||
# 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})")
|
||||
|
||||
# Check if we should skip this movie (unless forced or webhook mode)
|
||||
if not force_scan and not webhook_mode:
|
||||
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
|
||||
if should_skip:
|
||||
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||
# Still update the movie record to track that we've seen it
|
||||
self.db.upsert_movie(imdb_id, str(movie_path))
|
||||
return "skipped"
|
||||
else:
|
||||
_log("INFO", f"🎬 PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||
elif force_scan:
|
||||
_log("INFO", f"🔄 FORCE PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Force scan enabled")
|
||||
else:
|
||||
_log("INFO", f"📥 WEBHOOK PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Webhook mode")
|
||||
|
||||
# Check for shutdown signal early in processing
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
# 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} - skipping database entry")
|
||||
return "no_video_files"
|
||||
|
||||
# TIER 1: Check database first (fastest - local lookup)
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
||||
|
||||
# Enhanced debug for database state
|
||||
if existing:
|
||||
has_dateadded = bool(existing.get("dateadded"))
|
||||
source_value = existing.get("source")
|
||||
_log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: has_dateadded={has_dateadded}, source='{source_value}', dateadded='{existing.get('dateadded')}'")
|
||||
else:
|
||||
_log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: No database record found")
|
||||
|
||||
# If we have complete data in database, use it and skip all other checks
|
||||
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||
_log("INFO", f"✅ TIER 1 - Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})")
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
|
||||
# Convert datetime objects to strings for NFO manager
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded = dateadded.isoformat()
|
||||
if released and hasattr(released, 'isoformat'):
|
||||
released = released.isoformat()
|
||||
|
||||
# Create NFO with existing data and update files
|
||||
if config.manage_nfo:
|
||||
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-cached]")
|
||||
return "processed"
|
||||
else:
|
||||
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
|
||||
|
||||
# TIER 2: Check if NFO file has NFOGuard data and cache it in database
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
|
||||
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
|
||||
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
|
||||
|
||||
if nfo_data:
|
||||
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
|
||||
dateadded = nfo_data["dateadded"]
|
||||
source = nfo_data["source"]
|
||||
released = nfo_data.get("released")
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
|
||||
|
||||
# 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-cached]")
|
||||
return "processed"
|
||||
|
||||
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
|
||||
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
|
||||
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
|
||||
if existing_nfo_data and config.manual_scan_prioritize_nfo:
|
||||
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
|
||||
dateadded = existing_nfo_data["dateadded"]
|
||||
source = existing_nfo_data["source"]
|
||||
released = existing_nfo_data.get("released")
|
||||
|
||||
# Cache existing data in database and add proper NFOGuard formatting
|
||||
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
|
||||
|
||||
# Update NFO file to add NFOGuard formatting (lockdata, comment)
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
|
||||
|
||||
# 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}) [existing-nfo-enhanced]")
|
||||
return "processed"
|
||||
elif existing_nfo_data:
|
||||
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
|
||||
# Store NFO data as fallback but continue to TIER 3 to check external APIs
|
||||
nfo_fallback_data = existing_nfo_data
|
||||
|
||||
# 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 "processed"
|
||||
|
||||
# TIER 3: No cached data found - proceed with API lookups and verification
|
||||
|
||||
# Check for shutdown signal before expensive API operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing before API calls: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
# TIER 3: No cached data found - determine if we should query APIs
|
||||
if webhook_mode:
|
||||
_log("INFO", f"Webhook processing - no cached data found, using full date decision logic")
|
||||
should_query = True # Always query for webhooks when no cached data exists
|
||||
else:
|
||||
# Manual scan mode - determine if we should query APIs
|
||||
should_query = config.movie_poll_mode == "always"
|
||||
_log("DEBUG", f"Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}")
|
||||
|
||||
# Use existing movie date decision logic
|
||||
# Pass NFO fallback data if available for cases where external APIs don't have import history
|
||||
nfo_fallback = locals().get('nfo_fallback_data', None)
|
||||
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, nfo_fallback)
|
||||
|
||||
# Webhook fallback: if ALL date sources fail, use current timestamp
|
||||
if webhook_mode and 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"
|
||||
|
||||
# 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.debug:
|
||||
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
|
||||
if config.manage_nfo:
|
||||
if config.debug:
|
||||
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:
|
||||
if config.debug:
|
||||
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:
|
||||
_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 "processed"
|
||||
|
||||
# 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}")
|
||||
|
||||
# Yield control briefly during movie processing to allow web interface requests
|
||||
import time
|
||||
time.sleep(0.005) # 5ms yield per movie to improve responsiveness
|
||||
|
||||
# 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})")
|
||||
return "processed"
|
||||
|
||||
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 _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract any valid date information from NFO file, even without NFOGuard markers"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for dateadded element (indicates previously processed by NFOGuard or similar)
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
premiered_elem = root.find('.//premiered')
|
||||
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
dateadded = dateadded_elem.text.strip()
|
||||
|
||||
# Try to determine source from NFOGuard comment if present
|
||||
source = "existing_nfo_data"
|
||||
try:
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
# Look for NFOGuard comment pattern
|
||||
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
|
||||
else:
|
||||
# Try to infer source from dateadded format/content
|
||||
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text):
|
||||
source = "tmdb:digital"
|
||||
elif "radarr" in nfo_content.lower():
|
||||
source = "radarr:db.history.import"
|
||||
else:
|
||||
source = "existing_nfo_data"
|
||||
_log("DEBUG", f"Inferred source from NFO content: {source}")
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Could not determine source from NFO content: {e}")
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded,
|
||||
"source": source
|
||||
}
|
||||
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
result["released"] = premiered_elem.text.strip()
|
||||
|
||||
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
_log("DEBUG", f"Error parsing NFO for existing date data: {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: check if we have NFO fallback data (when external APIs don't have import history)
|
||||
if existing and existing.get('dateadded'):
|
||||
_log("INFO", f"✅ Movie {imdb_id}: External APIs don't have import history, using NFO fallback date: {existing['dateadded']} (source: {existing['source']})")
|
||||
return existing["dateadded"], f"nfo_fallback:{existing['source']}", existing.get("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 <premiered>YYYY-MM-DD</premiered>
|
||||
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', 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 <premiered> 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TV Series Processor - Clean implementation for TV episode processing
|
||||
Handles manual scans and webhook processing with proper NFO filename matching
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.episode_nfo_manager import EpisodeNFOManager
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from clients.external_clients import ExternalClientManager
|
||||
from core.logging import _log, convert_utc_to_local
|
||||
|
||||
|
||||
class TVSeriesProcessor:
|
||||
"""Clean TV series processor with video filename matching"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager,
|
||||
path_mapper: PathMapper, sonarr_client: SonarrClient):
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager # Keep for series/season NFOs
|
||||
self.episode_nfo_manager = EpisodeNFOManager()
|
||||
self.path_mapper = path_mapper
|
||||
self.sonarr = sonarr_client
|
||||
self.external_manager = ExternalClientManager()
|
||||
|
||||
def process_series_manual_scan(self, series_path: Path) -> bool:
|
||||
"""Process a TV series during manual scan"""
|
||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||
|
||||
# Extract IMDb ID
|
||||
imdb_id = self._extract_imdb_id(series_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found for series: {series_path.name}")
|
||||
return False
|
||||
|
||||
# Find all episodes on disk
|
||||
episodes_on_disk = self._find_episodes_on_disk(series_path)
|
||||
if not episodes_on_disk:
|
||||
_log("WARNING", f"No episodes found on disk for: {series_path.name}")
|
||||
return False
|
||||
|
||||
_log("INFO", f"Found {len(episodes_on_disk)} episodes on disk")
|
||||
|
||||
# Process each episode
|
||||
episodes_processed = 0
|
||||
for (season_num, episode_num), video_files in episodes_on_disk.items():
|
||||
if self._process_episode_manual_scan(series_path, imdb_id, season_num, episode_num):
|
||||
episodes_processed += 1
|
||||
|
||||
# Create series-level NFOs if any episodes were processed
|
||||
if episodes_processed > 0:
|
||||
self._create_series_nfos(series_path, imdb_id)
|
||||
|
||||
_log("INFO", f"Completed processing series: {series_path.name} ({episodes_processed} episodes)")
|
||||
return episodes_processed > 0
|
||||
|
||||
def process_episode_webhook(self, webhook_data: Dict[str, Any]) -> bool:
|
||||
"""Process a single episode from Sonarr webhook"""
|
||||
# TODO: Parse webhook data and extract episode info
|
||||
# This will be implemented when we add webhook support
|
||||
pass
|
||||
|
||||
def _extract_imdb_id(self, series_path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from series directory or files"""
|
||||
# Try directory name first
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try tvshow.nfo if it exists
|
||||
tvshow_nfo = series_path / "tvshow.nfo"
|
||||
if tvshow_nfo.exists():
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_nfo(tvshow_nfo)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try any existing episode NFO files
|
||||
for season_dir in series_path.iterdir():
|
||||
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||
for nfo_file in season_dir.glob("*.nfo"):
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_nfo(nfo_file)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
|
||||
def _is_season_directory(self, dirname: str) -> bool:
|
||||
"""Check if directory name matches season pattern"""
|
||||
return bool(re.match(r'^[Ss]eason\s+\d+$', dirname, re.IGNORECASE))
|
||||
|
||||
def _find_episodes_on_disk(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||
"""Find all episodes on disk, grouped by (season, episode)"""
|
||||
episodes = {}
|
||||
|
||||
try:
|
||||
_log("DEBUG", f"Scanning for season directories in: {series_path}")
|
||||
_log("DEBUG", f"Series path exists: {series_path.exists()}, is_dir: {series_path.is_dir()}")
|
||||
|
||||
if not series_path.exists() or not series_path.is_dir():
|
||||
_log("ERROR", f"Series path does not exist or is not a directory: {series_path}")
|
||||
return episodes
|
||||
|
||||
# List all items in directory for debugging
|
||||
try:
|
||||
items = list(series_path.iterdir())
|
||||
_log("DEBUG", f"Found {len(items)} items in series directory")
|
||||
for item in items:
|
||||
_log("DEBUG", f" Item: {item.name} (is_dir: {item.is_dir()})")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to list directory contents: {e}")
|
||||
return episodes
|
||||
|
||||
for season_dir in series_path.iterdir():
|
||||
_log("DEBUG", f"Checking directory: {season_dir.name} (is_dir: {season_dir.is_dir()})")
|
||||
_log("DEBUG", f"Season directory regex test for '{season_dir.name}': {self._is_season_directory(season_dir.name)}")
|
||||
|
||||
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||
season_num = self._extract_season_number(season_dir.name)
|
||||
_log("DEBUG", f"Found season directory: {season_dir.name} → season {season_num}")
|
||||
if season_num is None:
|
||||
_log("WARNING", f"Could not extract season number from: {season_dir.name}")
|
||||
continue
|
||||
|
||||
# Find video files in this season
|
||||
season_episodes = self.episode_nfo_manager.find_video_files_for_season(season_dir)
|
||||
_log("DEBUG", f"Found {len(season_episodes)} episodes in {season_dir.name}: {list(season_episodes.keys())}")
|
||||
|
||||
# Add season directory info to episodes
|
||||
for (s_num, e_num), video_files in season_episodes.items():
|
||||
_log("DEBUG", f"Episode S{s_num:02d}E{e_num:02d}: season_dir={season_num}, filename_season={s_num}")
|
||||
if s_num == season_num: # Verify season matches directory
|
||||
episodes[(s_num, e_num)] = video_files
|
||||
_log("DEBUG", f"Added episode S{s_num:02d}E{e_num:02d} to processing list")
|
||||
else:
|
||||
_log("WARNING", f"Season mismatch: directory={season_num}, filename={s_num} for S{s_num:02d}E{e_num:02d}")
|
||||
|
||||
_log("DEBUG", f"Total episodes found on disk: {len(episodes)}")
|
||||
return episodes
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Exception in _find_episodes_on_disk: {e}")
|
||||
return episodes
|
||||
|
||||
def _extract_season_number(self, dirname: str) -> Optional[int]:
|
||||
"""Extract season number from directory name"""
|
||||
match = re.search(r'[Ss]eason\s+(\d+)', dirname, re.IGNORECASE)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
def _process_episode_manual_scan(self, series_path: Path, imdb_id: str,
|
||||
season_num: int, episode_num: int) -> bool:
|
||||
"""Process a single episode during manual scan"""
|
||||
season_dir = series_path / f"Season {season_num:02d}"
|
||||
if not season_dir.exists():
|
||||
# Try alternate format
|
||||
season_dir = series_path / f"Season {season_num}"
|
||||
if not season_dir.exists():
|
||||
_log("ERROR", f"Season directory not found for S{season_num:02d}E{episode_num:02d}")
|
||||
return False
|
||||
|
||||
_log("DEBUG", f"Processing episode S{season_num:02d}E{episode_num:02d}")
|
||||
|
||||
# Step 1: Check for existing NFOGuard data
|
||||
existing_nfo = self.episode_nfo_manager.find_nfo_for_episode(season_dir, season_num, episode_num)
|
||||
if existing_nfo:
|
||||
nfo_data = self.episode_nfo_manager.extract_nfoguard_data(existing_nfo)
|
||||
if nfo_data:
|
||||
# Verify against database
|
||||
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if db_data and db_data.get("dateadded") == nfo_data.get("dateadded"):
|
||||
_log("DEBUG", f"Episode S{season_num:02d}E{episode_num:02d} already up to date")
|
||||
# Still migrate filename if needed
|
||||
self.episode_nfo_manager.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
|
||||
return True
|
||||
|
||||
# Step 2: Check database
|
||||
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if db_data and db_data.get("dateadded"):
|
||||
_log("DEBUG", f"Using database data for S{season_num:02d}E{episode_num:02d}")
|
||||
aired = db_data.get("aired")
|
||||
dateadded = db_data.get("dateadded")
|
||||
source = db_data.get("source", "database")
|
||||
else:
|
||||
# Step 3: Query Sonarr for episode data
|
||||
aired, dateadded, source = self._get_episode_dates_from_sonarr(imdb_id, season_num, episode_num)
|
||||
|
||||
# Step 4: Create/update NFO and database
|
||||
if dateadded or aired:
|
||||
# Get episode metadata for title/plot
|
||||
title, plot = self._get_episode_metadata_from_sonarr(imdb_id, season_num, episode_num)
|
||||
|
||||
# Use aired date as dateadded if no import date found (user requirement)
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
if source == "no_data_found":
|
||||
source = "tmdb:air_date_fallback"
|
||||
else:
|
||||
source = f"{source}_used_as_dateadded"
|
||||
_log("INFO", f"Using aired date as dateadded for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
|
||||
# Create/update NFO with video filename
|
||||
success = self.episode_nfo_manager.create_episode_nfo(
|
||||
season_dir, season_num, episode_num, aired, dateadded, source, title, plot
|
||||
)
|
||||
|
||||
if success:
|
||||
# Update database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||
return True
|
||||
|
||||
_log("WARNING", f"Could not get dates for episode S{season_num:02d}E{episode_num:02d}")
|
||||
return False
|
||||
|
||||
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""Get episode dates from Sonarr API with proper fallback"""
|
||||
aired = None
|
||||
dateadded = None
|
||||
source = "no_data_found"
|
||||
|
||||
if not self.sonarr.enabled:
|
||||
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
|
||||
return aired, dateadded, source
|
||||
|
||||
try:
|
||||
# Find series in Sonarr using lookup endpoint first
|
||||
series = self.sonarr.series_by_imdb(imdb_id)
|
||||
if not series:
|
||||
_log("WARNING", f"Series not found via Sonarr lookup for IMDb: {imdb_id}")
|
||||
# Try direct method as fallback (slower but more reliable)
|
||||
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
|
||||
series = self.sonarr.series_by_imdb_direct(imdb_id)
|
||||
if not series:
|
||||
_log("WARNING", f"Series not found via direct search either for IMDb: {imdb_id}")
|
||||
# Fall through to external API fallback
|
||||
|
||||
if series:
|
||||
# Get episodes for series
|
||||
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||
target_episode = None
|
||||
|
||||
for episode in episodes:
|
||||
if (episode.get("seasonNumber") == season_num and
|
||||
episode.get("episodeNumber") == episode_num):
|
||||
target_episode = episode
|
||||
break
|
||||
|
||||
if not target_episode:
|
||||
_log("WARNING", f"Episode S{season_num:02d}E{episode_num:02d} not found in Sonarr")
|
||||
# Don't return here - fall through to external API fallback
|
||||
else:
|
||||
# Get airdate
|
||||
aired = target_episode.get("airDateUtc")
|
||||
|
||||
# Try to get import history
|
||||
episode_id = target_episode.get("id")
|
||||
if episode_id:
|
||||
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||
if import_date:
|
||||
dateadded = convert_utc_to_local(import_date)
|
||||
source = "sonarr:history.import"
|
||||
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Fallback to airdate if no import history
|
||||
if aired:
|
||||
dateadded = convert_utc_to_local(aired)
|
||||
source = "sonarr:episode.airDateUtc"
|
||||
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Sonarr API error for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
# Try external APIs for episode airdate
|
||||
_log("INFO", f"Trying external APIs for episode S{season_num:02d}E{episode_num:02d} airdate")
|
||||
aired, source = self._get_episode_airdate_from_external_apis(imdb_id, season_num, episode_num)
|
||||
if aired:
|
||||
dateadded = convert_utc_to_local(aired)
|
||||
return aired, dateadded, source
|
||||
|
||||
_log("ERROR", f"Could not get any date information for S{season_num:02d}E{episode_num:02d}")
|
||||
return aired, dateadded, source
|
||||
|
||||
def _get_episode_metadata_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Get episode title and plot from Sonarr"""
|
||||
if not self.sonarr.enabled:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
series = self.sonarr.series_by_imdb(imdb_id)
|
||||
if not series:
|
||||
# Try direct method as fallback
|
||||
series = self.sonarr.series_by_imdb_direct(imdb_id)
|
||||
|
||||
if series:
|
||||
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||
for episode in episodes:
|
||||
if (episode.get("seasonNumber") == season_num and
|
||||
episode.get("episodeNumber") == episode_num):
|
||||
title = episode.get("title")
|
||||
plot = episode.get("overview")
|
||||
return title, plot
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Could not get metadata for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
return None, None
|
||||
|
||||
def _get_episode_airdate_from_external_apis(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], str]:
|
||||
"""Get episode airdate from external APIs (TMDB, OMDb) as fallback"""
|
||||
# Try TMDB first
|
||||
if self.external_manager.tmdb.enabled:
|
||||
try:
|
||||
_log("DEBUG", f"Trying TMDB for episode S{season_num:02d}E{episode_num:02d} airdate")
|
||||
|
||||
# First convert IMDb to TMDB TV ID
|
||||
tv_search = self.external_manager.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
|
||||
if tv_search and tv_search.get("tv_results"):
|
||||
tv_id = tv_search["tv_results"][0].get("id")
|
||||
if tv_id:
|
||||
_log("DEBUG", f"Found TMDB TV ID {tv_id} for {imdb_id}")
|
||||
|
||||
# Get episode details
|
||||
episode_data = self.external_manager.tmdb._get(f"/tv/{tv_id}/season/{season_num}/episode/{episode_num}")
|
||||
if episode_data and episode_data.get("air_date"):
|
||||
airdate = episode_data["air_date"]
|
||||
# Convert to ISO format with UTC timezone
|
||||
iso_airdate = f"{airdate}T00:00:00Z"
|
||||
_log("INFO", f"Found TMDB airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
|
||||
return iso_airdate, "tmdb:episode.air_date"
|
||||
except Exception as e:
|
||||
_log("WARNING", f"TMDB episode lookup failed for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
# Try OMDb as fallback
|
||||
if self.external_manager.omdb.enabled:
|
||||
try:
|
||||
_log("DEBUG", f"Trying OMDb for episode S{season_num:02d}E{episode_num:02d} airdate")
|
||||
episode_dates = self.external_manager.omdb.get_tv_season_episodes(imdb_id, season_num)
|
||||
if episode_num in episode_dates:
|
||||
airdate = episode_dates[episode_num]
|
||||
# Convert to ISO format
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
# Try to parse OMDb date format (usually DD MMM YYYY)
|
||||
dt = datetime.strptime(airdate, "%d %b %Y").replace(tzinfo=timezone.utc)
|
||||
iso_airdate = dt.isoformat(timespec="seconds")
|
||||
_log("INFO", f"Found OMDb airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
|
||||
return iso_airdate, "omdb:episode.released"
|
||||
except ValueError:
|
||||
# Try other common formats
|
||||
for fmt in ["%Y-%m-%d", "%d %B %Y"]:
|
||||
try:
|
||||
dt = datetime.strptime(airdate, fmt).replace(tzinfo=timezone.utc)
|
||||
iso_airdate = dt.isoformat(timespec="seconds")
|
||||
_log("INFO", f"Found OMDb airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
|
||||
return iso_airdate, "omdb:episode.released"
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e:
|
||||
_log("WARNING", f"OMDb episode lookup failed for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
_log("WARNING", f"No external API airdate found for S{season_num:02d}E{episode_num:02d}")
|
||||
return None, "no_external_data"
|
||||
|
||||
def _create_series_nfos(self, series_path: Path, imdb_id: str):
|
||||
"""Create tvshow.nfo and season.nfo files only if they don't exist"""
|
||||
# Create tvshow.nfo only if it doesn't exist
|
||||
tvshow_nfo = series_path / "tvshow.nfo"
|
||||
if not tvshow_nfo.exists():
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
|
||||
else:
|
||||
_log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}")
|
||||
|
||||
# Create season.nfo for each season directory only if they don't exist
|
||||
for season_dir in series_path.iterdir():
|
||||
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||
season_num = self._extract_season_number(season_dir.name)
|
||||
if season_num is not None:
|
||||
season_nfo = season_dir / "season.nfo"
|
||||
if not season_nfo.exists():
|
||||
self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
else:
|
||||
_log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}")
|
||||
Reference in New Issue
Block a user