From b649560463234efcbe5f33a258f35f7e1f858f5e Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Thu, 25 Sep 2025 14:14:56 -0400 Subject: [PATCH] feat: Merge dev branch with TV episode title extraction and movie webhook fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Features and Fixes: - Comprehensive TV episode title extraction from filenames when metadata missing - Fixed movie webhook processing errors and batch validation - Improved NFO field organization with NFOGuard comment at bottom - Enhanced IMDb detection using directory, filename, and NFO content - Fixed WebhookBatcher missing nfo_manager attribute - Version updated to 1.8.3 TV Episode Improvements: - Added filename-based title extraction for episodes missing titles - Enhanced existing NFO files with missing titles - Fixed long-named NFO file migration and standardization - Improved episode processing with comprehensive debugging Movie Webhook Fixes: - Fixed 'name path is not defined' error in batch processing - Replaced simple string search with comprehensive IMDb detection - Consistent validation between webhook reception and batch processing - Better error handling and logging for debugging NFO Organization: - Moved NFOGuard comment from top to bottom with other fields - All NFOGuard additions now grouped consistently - Improved XML structure and file writing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- VERSION | 2 +- core/nfo_manager.py | 145 +++++++++++++++++++++++++++++++++++---- nfoguard.py | 164 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 273 insertions(+), 38 deletions(-) diff --git a/VERSION b/VERSION index 27f9cd3..fe4e75f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.0 +1.8.3 \ No newline at end of file diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 09c4e29..6e47cf2 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -163,7 +163,7 @@ class NFOManager: def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]: """Extract NFOGuard-managed dates from existing episode NFO file""" - nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo" + nfo_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" nfo_path = season_path / nfo_filename if not nfo_path.exists(): @@ -190,7 +190,11 @@ class NFOManager: if aired_elem is not None and aired_elem.text: result["aired"] = aired_elem.text.strip() - print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result['dateadded']}, aired={result.get('aired', 'None')}") + # Check if title is missing + title_elem = root.find('.//title') + result["has_title"] = title_elem is not None and title_elem.text and title_elem.text.strip() + + print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result['dateadded']}, aired={result.get('aired', 'None')}, has_title={result['has_title']}") return result except (ET.ParseError, Exception) as e: @@ -199,6 +203,104 @@ class NFOManager: return None + def enhance_existing_episode_nfo_with_title(self, season_path: Path, season_num: int, episode_num: int, title: str) -> bool: + """Add title to existing episode NFO file that's missing one""" + nfo_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" + nfo_path = season_path / nfo_filename + + if not nfo_path.exists(): + return False + + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + + # Check if title already exists + existing_title = root.find('.//title') + if existing_title is not None and existing_title.text and existing_title.text.strip(): + return False # Title already exists + + # Add title element (insert near the top, after any existing plot element) + title_elem = ET.Element("title") + title_elem.text = title + + # Find the best position to insert title (after plot if it exists, otherwise at the beginning) + plot_elem = root.find('.//plot') + if plot_elem is not None: + # Insert after plot + plot_index = list(root).index(plot_elem) + root.insert(plot_index + 1, title_elem) + else: + # Insert at the beginning (after any existing title that might be empty) + if existing_title is not None: + title_index = list(root).index(existing_title) + root.remove(existing_title) + root.insert(title_index, title_elem) + else: + root.insert(0, title_elem) + + # Write the updated NFO + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) + print(f"📝 Enhanced existing NFO with title: S{season_num:02d}E{episode_num:02d} - '{title}'") + return True + + except Exception as e: + print(f"⚠️ Error enhancing NFO with title: {e}") + return False + + def _extract_title_from_filename(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[str]: + """Extract episode title from video filename as fallback when metadata doesn't provide it""" + try: + import re + # Look for video files matching this episode + season_pattern = f"S{season_num:02d}E{episode_num:02d}" + print(f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}") + + for video_file in season_dir.glob("*.mkv"): + filename = video_file.name + print(f"🔍 Checking file: {filename}") + if season_pattern in filename: + print(f"✅ Found matching file: {filename}") + # Pattern: SeriesName-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv + # Extract the part between season/episode and the first bracket + pattern = rf'{season_pattern}-(.*?)\[' + print(f"🔍 Using regex pattern: {pattern}") + match = re.search(pattern, filename) + if match: + title = match.group(1).strip() + print(f"🔍 Raw extracted title: '{title}'") + # Clean up common encoding artifacts and separators + title = title.replace('-', ' ').strip() + if title: + print(f"✅ Extracted title from filename: '{title}' for {season_pattern}") + return title + else: + print(f"⚠️ Title was empty after cleanup") + else: + print(f"⚠️ Regex pattern didn't match filename") + + # Also check .mp4 files + for video_file in season_dir.glob("*.mp4"): + filename = video_file.name + print(f"🔍 Checking .mp4 file: {filename}") + if season_pattern in filename: + print(f"✅ Found matching .mp4 file: {filename}") + pattern = rf'{season_pattern}-(.*?)\[' + match = re.search(pattern, filename) + if match: + title = match.group(1).strip() + print(f"🔍 Raw extracted title from .mp4: '{title}'") + title = title.replace('-', ' ').strip() + if title: + print(f"✅ Extracted title from .mp4 filename: '{title}' for {season_pattern}") + return title + + except Exception as e: + print(f"⚠️ Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + + print(f"⚠️ No title found in filenames for {season_pattern}") + return None + def _parse_nfo_with_tolerance(self, nfo_path: Path): """Parse NFO file with tolerance for URLs appended after XML""" try: @@ -594,6 +696,29 @@ class NFOManager: runtime_elem = ET.SubElement(episode, "runtime") runtime_elem.text = str(enhanced_metadata["runtime"]) + # Fallback: Extract title from filename if no valid title present + title_elem = episode.find("title") + has_valid_title = title_elem is not None and title_elem.text and title_elem.text.strip() + + print(f"🔍 Title check for S{season_num:02d}E{episode_num:02d}: has_element={title_elem is not None}, has_text={title_elem.text if title_elem is not None else 'N/A'}, has_valid_title={has_valid_title}") + + if not has_valid_title: + print(f"🔍 No valid title found in NFO for S{season_num:02d}E{episode_num:02d}, attempting filename extraction") + filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) + if filename_title: + # Remove existing empty/invalid title element if present + if title_elem is not None: + episode.remove(title_elem) + + # Add new title element + title_elem = ET.SubElement(episode, "title") + title_elem.text = filename_title + print(f"✅ Added filename-extracted title to NFO: S{season_num:02d}E{episode_num:02d} - '{filename_title}'") + else: + print(f"⚠️ Could not extract title from filename for S{season_num:02d}E{episode_num:02d}") + else: + print(f"✅ Valid title already exists in NFO for S{season_num:02d}E{episode_num:02d}: '{title_elem.text.strip()}')") + # Add NFOGuard fields at the bottom # Basic episode info at the end @@ -617,22 +742,16 @@ class NFOManager: lockdata = ET.SubElement(episode, "lockdata") lockdata.text = "true" - # Add NFOGuard comment at the beginning - comment_text = f" Created by {self.manager_brand} - Source: {source} " + # Add NFOGuard comment at the bottom with other NFOGuard fields + nfoguard_comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ") + episode.append(nfoguard_comment) # Write file with proper formatting tree = ET.ElementTree(episode) ET.indent(tree, space=" ", level=0) - # Write to string first to add comment - xml_str = ET.tostring(episode, encoding='unicode') - - # Add XML declaration and comment - full_xml = f'\n\n{xml_str}' - - # Write to file - with open(nfo_path, 'w', encoding='utf-8') as f: - f.write(full_xml) + # Write to file normally (ET will handle the comment properly) + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") diff --git a/nfoguard.py b/nfoguard.py index 2584c97..f29275c 100644 --- a/nfoguard.py +++ b/nfoguard.py @@ -568,7 +568,7 @@ class TVProcessor: for (season, episode), (aired, dateadded, source) in episode_dates.items(): if (season, episode) in disk_episodes: # Get enhanced episode metadata - enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None + enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode, season_path) if series_metadata else self._get_episode_metadata(None, season, episode, season_path) # Create NFO if config.manage_nfo: @@ -623,6 +623,21 @@ class TVProcessor: source = nfo_data["source"] aired = nfo_data.get("aired") + # Check if NFO is missing title and try to enhance it + if not nfo_data.get("has_title", False): + _log("INFO", f"NFO S{season_num:02d}E{episode_num:02d} missing title, attempting to extract from filename") + filename_title = self._extract_title_from_filename(season_path, season_num, episode_num) + if filename_title: + enhanced = self.nfo_manager.enhance_existing_episode_nfo_with_title( + season_path, season_num, episode_num, filename_title + ) + if enhanced: + _log("INFO", f"✅ Enhanced NFO S{season_num:02d}E{episode_num:02d} with title: '{filename_title}'") + else: + _log("WARNING", f"Failed to enhance NFO S{season_num:02d}E{episode_num:02d} with title") + else: + _log("WARNING", f"Could not extract title from filename for S{season_num:02d}E{episode_num:02d}") + # Update file mtime if enabled (NFO is already correct) if config.fix_dir_mtimes and dateadded: self.nfo_manager.set_file_mtime(episode_file, dateadded) @@ -639,12 +654,14 @@ class TVProcessor: source = existing_episode["source"] aired = existing_episode.get("aired") - # Create NFO with existing data (no enhanced metadata needed) + # Create NFO with existing data, but try to add title from filename if missing if config.manage_nfo and dateadded: + # Try to extract title from filename as fallback for Tier 2 processing + enhanced_metadata = self._get_episode_metadata(None, season_num, episode_num, season_path) self.nfo_manager.create_episode_nfo( season_path, season_num, episode_num, aired, dateadded, source, config.lock_metadata, - None # No enhanced metadata for database-cached episodes + enhanced_metadata # Include filename-extracted title if available ) # Update file mtime if enabled @@ -659,7 +676,7 @@ class TVProcessor: # Get enhanced metadata from Sonarr series_metadata = self._get_sonarr_series_metadata(imdb_id) - enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None + enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_path) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_path) # Get episode date aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) @@ -739,20 +756,97 @@ class TVProcessor: _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}") return None - def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int) -> Optional[Dict[str, Any]]: - """Extract specific episode metadata from series data""" - if not series_metadata or "episodes" not in series_metadata: - return None + def _get_episode_metadata(self, series_metadata: Dict[str, Any], season_num: int, episode_num: int, season_dir: Optional[Path] = None) -> Optional[Dict[str, Any]]: + """Extract specific episode metadata from series data with filename fallback for titles""" + _log("INFO", f"🔍 _get_episode_metadata called for S{season_num:02d}E{episode_num:02d}, season_dir: {season_dir}") + metadata = { + "title": None, + "overview": None, + "runtime": None, + "ratings": {} + } - for episode in series_metadata["episodes"]: - if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: - return { - "title": episode.get("title"), - "overview": episode.get("overview"), - "runtime": episode.get("runtime"), - "ratings": episode.get("ratings", {}) - } + # Try to get metadata from Sonarr first + if series_metadata and "episodes" in series_metadata: + for episode in series_metadata["episodes"]: + if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: + metadata.update({ + "title": episode.get("title"), + "overview": episode.get("overview"), + "runtime": episode.get("runtime"), + "ratings": episode.get("ratings", {}) + }) + break + # If no title from Sonarr, try to extract from filename + if not metadata["title"] and season_dir: + _log("INFO", f"📁 No title from Sonarr for S{season_num:02d}E{episode_num:02d}, trying filename extraction") + filename_title = self._extract_title_from_filename(season_dir, season_num, episode_num) + if filename_title: + metadata["title"] = filename_title + _log("INFO", f"✅ Using filename-extracted title for S{season_num:02d}E{episode_num:02d}: '{filename_title}'") + else: + _log("DEBUG", f"⚠️ No filename title extracted for S{season_num:02d}E{episode_num:02d}") + elif metadata["title"]: + _log("DEBUG", f"✅ Using Sonarr title for S{season_num:02d}E{episode_num:02d}: '{metadata['title']}'") + + # Return metadata if we have at least some information + if any(metadata.values()): + return metadata + + return None + + def _extract_title_from_filename(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[str]: + """Extract episode title from video filename as fallback when Sonarr doesn't provide it""" + try: + import re + # Look for video files matching this episode + season_pattern = f"S{season_num:02d}E{episode_num:02d}" + _log("INFO", f"🔍 Searching for title in files for {season_pattern} in directory: {season_dir}") + + for video_file in season_dir.glob("*.mkv"): + filename = video_file.name + _log("DEBUG", f"🔍 Checking file: {filename}") + if season_pattern in filename: + _log("DEBUG", f"✅ Found matching file: {filename}") + # Pattern: SeriesName-S01E01-Episode Title[WEBDL-1080p][AAC2.0][h264].mkv + # Extract the part between season/episode and the first bracket + pattern = rf'{season_pattern}-(.*?)\[' + _log("DEBUG", f"🔍 Using regex pattern: {pattern}") + match = re.search(pattern, filename) + if match: + title = match.group(1).strip() + _log("DEBUG", f"🔍 Raw extracted title: '{title}'") + # Clean up common encoding artifacts and separators + title = title.replace('-', ' ').strip() + if title: + _log("INFO", f"✅ Extracted title from filename: '{title}' for {season_pattern}") + return title + else: + _log("DEBUG", f"⚠️ Title was empty after cleanup") + else: + _log("DEBUG", f"⚠️ Regex pattern didn't match filename") + + # Also check .mp4 files + for video_file in season_dir.glob("*.mp4"): + filename = video_file.name + _log("DEBUG", f"🔍 Checking .mp4 file: {filename}") + if season_pattern in filename: + _log("DEBUG", f"✅ Found matching .mp4 file: {filename}") + pattern = rf'{season_pattern}-(.*?)\[' + match = re.search(pattern, filename) + if match: + title = match.group(1).strip() + _log("DEBUG", f"🔍 Raw extracted title from .mp4: '{title}'") + title = title.replace('-', ' ').strip() + if title: + _log("INFO", f"✅ Extracted title from .mp4 filename: '{title}' for {season_pattern}") + return title + + except Exception as e: + _log("ERROR", f"Error extracting title from filename for S{season_num:02d}E{episode_num:02d}: {e}") + + _log("DEBUG", f"⚠️ No title found in filenames for {season_pattern}") return None def _gather_season_episode_dates(self, series_path: Path, imdb_id: str, season_num: int, disk_episodes: Dict[Tuple[int, int], List[Path]], series_metadata: Optional[Dict[str, Any]] = None) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]: @@ -869,7 +963,7 @@ class TVProcessor: # 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) if series_metadata else None + 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: @@ -1468,12 +1562,13 @@ class MovieProcessor: class WebhookBatcher: """Batches webhook events to avoid processing storms""" - def __init__(self): + def __init__(self, nfo_manager: NFOManager): self.pending: Dict[str, Dict] = {} self.timers: Dict[str, threading.Timer] = {} self.processing: Set[str] = set() self.lock = threading.Lock() self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent) + self.nfo_manager = nfo_manager def add_webhook(self, key: str, webhook_data: Dict, media_type: str): """Add webhook to batch queue""" @@ -1527,11 +1622,23 @@ class WebhookBatcher: # CRITICAL: Validate that the path contains the expected IMDb ID for movies if media_type == 'movie': expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key - if expected_imdb not in path_str.lower(): + # Use comprehensive IMDb detection (directory, filenames, NFO content) + detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj) + + # Check if detected IMDb matches expected (handle both full IMDb IDs and just numbers) + imdb_match = False + if detected_imdb: + if detected_imdb == expected_imdb: + imdb_match = True + elif detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''): + imdb_match = True + + if not imdb_match: _log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str}") + _log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}") _log("ERROR", f"This prevents processing wrong movies due to batch corruption") return - _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path") + _log("DEBUG", f"Batch validation passed: Expected IMDb {expected_imdb} matches detected IMDb {detected_imdb}") # Process based on media type if media_type == 'tv': @@ -1614,7 +1721,7 @@ nfo_manager = NFOManager(config.manager_brand, config.debug) path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper tv_processor = TVProcessor(db, nfo_manager, path_mapper) movie_processor = MovieProcessor(db, nfo_manager, path_mapper) -batcher = WebhookBatcher() +batcher = WebhookBatcher(nfo_manager) # --------------------------- # Webhook Handlers @@ -1727,9 +1834,18 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): _log("ERROR", f"This prevents processing wrong movies due to path mapping issues") return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"} - # Verify the path contains the expected IMDb ID - if imdb_id not in container_path.lower(): - _log("WARNING", f"IMDb ID {imdb_id} not found in container path {container_path}") + # Verify the path contains the expected IMDb ID using comprehensive detection + detected_imdb = nfo_manager.find_movie_imdb_id(Path(container_path)) + imdb_match = False + if detected_imdb: + if detected_imdb == imdb_id or detected_imdb.replace('tt', '') == imdb_id.replace('tt', ''): + imdb_match = True + + if not imdb_match: + _log("WARNING", f"IMDb ID {imdb_id} not found via comprehensive detection in {container_path}") + _log("DEBUG", f"Detected IMDb: {detected_imdb}, Expected: {imdb_id}") + else: + _log("DEBUG", f"IMDb ID validated: {imdb_id} matches detected {detected_imdb}") # Create movie-specific webhook data with proper path validation movie_webhook_data = {