From f4d6f58d4627b7ca82db78849af7e08cadb23a2e Mon Sep 17 00:00:00 2001 From: sbcrumb Date: Thu, 9 Oct 2025 18:02:24 -0400 Subject: [PATCH] Fix TV webhook processing: Add missing process_webhook_episodes method - Add process_webhook_episodes method to TVProcessor class for targeted episode processing - Implement helper methods: _parse_episode_from_filename, _get_sonarr_series_metadata, _get_episode_metadata, _extract_title_from_filename, _get_webhook_episode_date - Enables webhook-based episode processing with title extraction and metadata handling - Resolves 'TVProcessor' object has no attribute 'process_webhook_episodes' error - Version bump to 2.0.10 --- VERSION | 2 +- processors/tv_processor.py | 223 +++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 09843e3..0a69206 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.9 +2.0.10 diff --git a/processors/tv_processor.py b/processors/tv_processor.py index dccd780..0e54de8 100644 --- a/processors/tv_processor.py +++ b/processors/tv_processor.py @@ -459,6 +459,229 @@ class TVProcessor: return processed_results + def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None: + """Process only the specific episodes mentioned in a webhook (targeted mode)""" + imdb_id = self.nfo_manager.parse_imdb_from_path(series_path) + if not imdb_id: + _log("ERROR", f"No IMDb ID found in series path: {series_path}") + return + + if not webhook_episodes: + _log("WARNING", f"No episodes in webhook, falling back to series processing: {series_path}") + self.process_series(series_path) + return + + _log("INFO", f"Processing {len(webhook_episodes)} webhook episodes for: {series_path.name} (IMDb: {imdb_id})") + + # Update database + self.db.upsert_series(imdb_id, str(series_path)) + + # Get enhanced metadata from Sonarr + series_metadata = self._get_sonarr_series_metadata(imdb_id) + + episodes_processed = 0 + for webhook_episode in webhook_episodes: + season_num = webhook_episode.get("seasonNumber") + episode_num = webhook_episode.get("episodeNumber") + + if not season_num or not episode_num: + _log("WARNING", f"Invalid episode data in webhook: {webhook_episode}") + continue + + # Check if episode file exists on disk + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + if not season_dir.exists(): + _log("WARNING", f"Season directory not found: {season_dir}") + continue + + # Find matching episode files + episode_files = [] + for file_path in season_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'): + parsed = self._parse_episode_from_filename(file_path.name) + if parsed and parsed == (season_num, episode_num): + episode_files.append(file_path) + + if not episode_files: + _log("WARNING", f"No video files found for S{season_num:02d}E{episode_num:02d}") + continue + + # Get episode date information - webhook processing prioritizes existing DB entries + _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") + aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata) + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir) + + # Create NFO + if config.manage_nfo: + self.nfo_manager.create_episode_nfo( + season_dir, + season_num, episode_num, aired, dateadded, source, config.lock_metadata, + enhanced_metadata + ) + + # Update file mtimes + if config.fix_dir_mtimes and dateadded: + for episode_file in episode_files: + self.nfo_manager.set_file_mtime(episode_file, dateadded) + + # Save to database + self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True) + + # Verify database entry was saved (debug) + verification = self.db.get_episode_date(imdb_id, season_num, episode_num) + if verification: + _log("DEBUG", f"Verified database entry saved: S{season_num:02d}E{episode_num:02d} -> {verification['dateadded']}") + else: + _log("ERROR", f"Failed to save episode to database: S{season_num:02d}E{episode_num:02d}") + + episodes_processed += 1 + + # Create season/tvshow NFOs if any episodes were processed + if episodes_processed > 0 and config.manage_nfo: + seasons_processed = set() + for webhook_episode in webhook_episodes: + season_num = webhook_episode.get("seasonNumber") + if season_num and season_num not in seasons_processed: + season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + if season_dir.exists(): + self.nfo_manager.create_season_nfo(season_dir, season_num) + seasons_processed.add(season_num) + + # Get TVDB ID for better Emby compatibility + tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + + _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") + + def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]: + """Parse season and episode numbers from filename""" + # Try SxxExx format + match = re.search(r'S(\d{1,2})E(\d{1,2})', filename, re.IGNORECASE) + if match: + return int(match.group(1)), int(match.group(2)) + + # Try season.episode format + match = re.search(r'(\d{1,2})\.(\d{1,2})', filename) + if match: + return int(match.group(1)), int(match.group(2)) + + return None + + def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: + """Get enhanced series metadata from Sonarr API""" + try: + if not self.sonarr.enabled: + return None + + series_data = self.sonarr.series_by_imdb(imdb_id) + if not series_data: + return None + + series_id = series_data.get('id') + if not series_id: + return None + + # Get all episodes for this series + episodes = self.sonarr.episodes_for_series(series_id) + + # Organize episodes by season/episode + episode_map = {} + for episode in episodes: + season = episode.get('seasonNumber', 0) + episode_num = episode.get('episodeNumber', 0) + + if season > 0 and episode_num > 0: + episode_map[(season, episode_num)] = episode + + return { + 'series': series_data, + 'episodes': episode_map + } + + except Exception as e: + _log("ERROR", f"Failed to get Sonarr series metadata for {imdb_id}: {e}") + return None + + def _get_episode_metadata(self, series_metadata: Optional[Dict[str, Any]], season_num: int, episode_num: int, season_dir: Optional[Path] = None) -> Optional[Dict[str, Any]]: + """Get enhanced episode metadata including title extraction from filename""" + _log("DEBUG", f"Getting episode metadata for S{season_num:02d}E{episode_num:02d}, season_dir: {season_dir}") + + metadata = {} + + # Try to get title from Sonarr first + if series_metadata and 'episodes' in series_metadata: + episode_data = series_metadata['episodes'].get((season_num, episode_num)) + if episode_data: + title = episode_data.get('title') + if title and title != 'TBA': + metadata['title'] = title + _log("DEBUG", f"Got title from Sonarr for S{season_num:02d}E{episode_num:02d}: {title}") + + # If no title from Sonarr, try to extract from filename + if 'title' not in metadata and season_dir: + title = self._extract_title_from_filename(season_num, episode_num, season_dir) + if title: + metadata['title'] = title + _log("DEBUG", f"Extracted title from filename for S{season_num:02d}E{episode_num:02d}: {title}") + + return metadata if metadata else None + + def _extract_title_from_filename(self, season_num: int, episode_num: int, season_dir: Path) -> Optional[str]: + """Extract episode title from video filename using regex pattern""" + season_pattern = f"S{season_num:02d}E{episode_num:02d}" + + try: + # Look for video files in the season directory + for file_path in season_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in ('.mkv', '.mp4', '.avi', '.mov', '.m4v'): + filename = file_path.name + + # Check if this file matches our season/episode + if season_pattern in filename.upper(): + # Extract title using regex pattern: S01E01-Title[WEBDL-1080p] + match = re.search(rf'{season_pattern}-(.*?)\[', filename, re.IGNORECASE) + if match: + title = match.group(1) + # Clean up the title + title = title.replace('-', ' ').strip() + if title: + _log("DEBUG", f"Extracted title '{title}' from filename: {filename}") + return title + + except Exception as e: + _log("ERROR", f"Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + + return None + + def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + """ + Get episode date for webhook processing with correct priority: + 1. Check existing NFOGuard database entry (preserve previous import dates) + 2. If not found, use current time (webhook = new download) + 3. Get aired date from Sonarr/TMDB for reference + """ + # Check if we already have this episode in our database (preserve existing dates) + existing = self.db.get_episode_date(imdb_id, season_num, episode_num) + if existing and existing.get('dateadded'): + _log("DEBUG", f"Found existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']}") + return existing.get('aired'), existing.get('dateadded'), existing.get('source', 'nfoguard:database') + + # Get aired date from Sonarr if available + aired = None + if series_metadata and 'episodes' in series_metadata: + episode_data = series_metadata['episodes'].get((season_num, episode_num)) + if episode_data: + aired = episode_data.get('airDate') + _log("DEBUG", f"Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}") + + # For webhook processing, use current time as dateadded (new download) + dateadded = datetime.now().isoformat() + source = "sonarr:webhook" + + _log("DEBUG", f"Using webhook date for S{season_num:02d}E{episode_num:02d}: {dateadded}") + + return aired, dateadded, source + async def async_batch_episode_processing( self, episodes_data: List[Dict[str, Any]],