#!/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 = {} 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 None: continue # Find video files in this season season_episodes = self.episode_nfo_manager.find_video_files_for_season(season_dir) # Add season directory info to episodes for (s_num, e_num), video_files in season_episodes.items(): if s_num == season_num: # Verify season matches directory episodes[(s_num, e_num)] = video_files 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: # Get episode metadata for title/plot title, plot = self._get_episode_metadata_from_sonarr(imdb_id, season_num, episode_num) # 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 series = self.sonarr.series_by_imdb(imdb_id) if not series: _log("WARNING", f"Series not found in Sonarr for IMDb: {imdb_id}") return aired, dateadded, source # 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") return aired, dateadded, source # 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 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""" # Create tvshow.nfo self.nfo_manager.create_tvshow_nfo(series_path, imdb_id) # Create season.nfo for each season directory 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: self.nfo_manager.create_season_nfo(season_dir, season_num)