dev to main (#14)
Local Docker Build (Main) / build (push) Successful in 20s
Local Docker Build (Main) / deploy (push) Successful in 0s

Reviewed-on: #14
Co-authored-by: SBCrumb <sbcrumb@kickthetree.com>
Co-committed-by: SBCrumb <sbcrumb@kickthetree.com>
This commit is contained in:
2025-09-27 11:19:36 -04:00
committed by sbcrumb
parent a4c333aa85
commit 2e7922dc8b
5 changed files with 822 additions and 378 deletions
+1 -1
View File
@@ -1 +1 @@
1.9.1 2.1.5-preserve-long-nfo-episodes-only
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
Core principle: NFO filenames should match video filenames
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
import re
from .logging import _log
class EpisodeNFOManager:
"""Manages episode NFO files with video filename matching"""
def __init__(self, manager_brand: str = "NFOGuard"):
self.manager_brand = manager_brand
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all video files in season directory, grouped by (season, episode)"""
if not season_dir.exists():
_log("DEBUG", f"Season directory does not exist: {season_dir}")
return {}
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
episodes = {}
_log("DEBUG", f"Scanning video files in: {season_dir}")
for video_file in season_dir.iterdir():
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
episode_info = self._parse_episode_from_filename(video_file.name)
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
if episode_info:
season_num, episode_num = episode_info
key = (season_num, episode_num)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d}{video_file.name}")
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
return episodes
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Extract season and episode numbers from filename"""
# Try S##E## format first (most common)
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find existing NFO file for episode (prefer video-matching filename)"""
if not season_dir.exists():
return None
# First, look for NFO files that match video filenames
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files:
for video_file in video_files[key]:
potential_nfo = season_dir / f"{video_file.stem}.nfo"
if potential_nfo.exists():
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
return potential_nfo
# Fallback: look for short name NFO
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
if short_nfo.exists():
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
return short_nfo
# Last resort: search all NFO files for matching season/episode data
for nfo_file in season_dir.glob("*.nfo"):
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
return nfo_file
return None
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
"""Check if NFO file contains the specified season/episode"""
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag == "episodedetails":
season_elem = root.find("season")
episode_elem = root.find("episode")
if (season_elem is not None and episode_elem is not None and
season_elem.text and episode_elem.text):
try:
file_season = int(season_elem.text)
file_episode = int(episode_elem.text)
return file_season == season_num and file_episode == episode_num
except ValueError:
pass
except (ET.ParseError, Exception):
pass
return False
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
"""Get the target NFO path (prefer video filename, fallback to short name)"""
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files and video_files[key]:
# Use the first video file found (handle multiple files gracefully)
video_file = video_files[key][0]
target_nfo = season_dir / f"{video_file.stem}.nfo"
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
return target_nfo
else:
# Fallback to short name if no video file found
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
return target_nfo
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
"""If short-name NFO exists, rename it to match video filename"""
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
# If we already have the right filename, nothing to do
if existing_nfo and existing_nfo == target_nfo:
return True
# If we have an NFO but it doesn't match target, rename it
if existing_nfo and existing_nfo != target_nfo:
try:
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
existing_nfo.rename(target_nfo)
return True
except Exception as e:
_log("ERROR", f"Failed to rename NFO: {e}")
return False
return False
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str,
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
"""Create or update episode NFO with video filename matching"""
# Get the target NFO path (matching video filename)
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
# Migrate existing NFO if needed
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
try:
# Load existing NFO if it exists
episode_elem = None
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
episode_elem = tree.getroot()
if episode_elem.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
# Remove NFOGuard-managed fields (we'll re-add them)
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
existing = episode_elem.find(tag)
if existing is not None:
episode_elem.remove(existing)
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
except (ET.ParseError, ValueError) as e:
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
episode_elem = None
# Create new structure if needed
if episode_elem is None:
episode_elem = ET.Element("episodedetails")
# Add title if provided and not already present
if title and not episode_elem.find("title"):
title_elem = ET.SubElement(episode_elem, "title")
title_elem.text = title
# Add plot if provided and not already present
if plot and not episode_elem.find("plot"):
plot_elem = ET.SubElement(episode_elem, "plot")
plot_elem.text = plot
# Add NFOGuard fields at the end
season_elem = ET.SubElement(episode_elem, "season")
season_elem.text = str(season_num)
episode_num_elem = ET.SubElement(episode_elem, "episode")
episode_num_elem.text = str(episode_num)
if aired:
aired_elem = ET.SubElement(episode_elem, "aired")
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
# Also add premiered for compatibility
premiered_elem = ET.SubElement(episode_elem, "premiered")
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
if dateadded:
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
dateadded_elem.text = dateadded
# Add lockdata
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
lockdata_elem.text = "true"
# Add comment with source
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode_elem.append(comment)
# Write the NFO file
tree = ET.ElementTree(episode_elem)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
return True
except Exception as e:
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
return False
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed data from existing NFO"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag != "episodedetails":
return None
# Look for NFOGuard fields
dateadded_elem = root.find("dateadded")
aired_elem = root.find("aired")
lockdata_elem = root.find("lockdata")
# Only consider it NFOGuard-managed if it has dateadded and lockdata
if (dateadded_elem is not None and dateadded_elem.text and
lockdata_elem is not None and lockdata_elem.text == "true"):
result = {
"dateadded": dateadded_elem.text.strip(),
"source": "existing_nfo"
}
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
return result
except (ET.ParseError, Exception) as e:
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
return None
+69 -175
View File
@@ -7,7 +7,7 @@ import os
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, Tuple
import re import re
@@ -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]]: 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""" """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 nfo_path = season_path / nfo_filename
if not nfo_path.exists(): if not nfo_path.exists():
@@ -190,11 +190,7 @@ class NFOManager:
if aired_elem is not None and aired_elem.text: if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip() result["aired"] = aired_elem.text.strip()
# Check if title is missing print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result['dateadded']}, aired={result.get('aired', 'None')}")
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 return result
except (ET.ParseError, Exception) as e: except (ET.ParseError, Exception) as e:
@@ -203,104 +199,6 @@ class NFOManager:
return None 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): def _parse_nfo_with_tolerance(self, nfo_path: Path):
"""Parse NFO file with tolerance for URLs appended after XML""" """Parse NFO file with tolerance for URLs appended after XML"""
try: try:
@@ -566,18 +464,12 @@ class NFOManager:
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}") print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]: def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find existing episode NFO file that matches season/episode but isn't standardized name""" """Find any existing episode NFO file that matches season/episode"""
if not season_dir.exists(): if not season_dir.exists():
return None return None
# Standard filename pattern we're looking to create
standard_pattern = f"S{season_num:02d}E{episode_num:02d}.nfo"
# Look for NFO files in the season directory # Look for NFO files in the season directory
for nfo_file in season_dir.glob("*.nfo"): for nfo_file in season_dir.glob("*.nfo"):
# Skip if it's already the standard format
if nfo_file.name == standard_pattern:
continue
# Check if this NFO contains the right season/episode # Check if this NFO contains the right season/episode
try: try:
@@ -596,7 +488,7 @@ class NFOManager:
file_episode = int(episode_elem.text) file_episode = int(episode_elem.text)
if file_season == season_num and file_episode == episode_num: if file_season == season_num and file_episode == episode_num:
print(f"🔍 Found existing episode NFO: {nfo_file.name} -> will migrate to {standard_pattern}") print(f"🔍 Found existing episode NFO: {nfo_file.name}")
return nfo_file return nfo_file
except ValueError: except ValueError:
continue continue
@@ -607,23 +499,62 @@ class NFOManager:
return None return None
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
if not season_dir.exists():
return f"S{season_num:02d}E{episode_num:02d}.nfo"
# First check if an NFO already exists for this episode
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
if existing_nfo:
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
return existing_nfo.name
# Look for video files with matching season/episode
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
for video_file in season_dir.iterdir():
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
# Parse episode info from video filename
episode_info = self._parse_episode_from_filename(video_file.name)
if episode_info and episode_info == (season_num, episode_num):
# Found matching video file - use its name for NFO
target_nfo_name = f"{video_file.stem}.nfo"
print(f"🎯 Target NFO will match video: {target_nfo_name}")
return target_nfo_name
# Fallback to short name if no matching video found
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
print(f"⚠️ No matching video file found, using short name: {short_name}")
return short_name
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Parse season and episode numbers from filename"""
# Try S##E## format first
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int, def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str, aired: Optional[str], dateadded: Optional[str], source: str,
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None: lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
"""Create or update episode NFO file preserving existing content""" """Create or update episode NFO file preserving existing content"""
# Generate episode filename pattern # Get target NFO filename (prefer long name matching video file)
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo" target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
nfo_path = season_dir / episode_filename nfo_path = season_dir / target_nfo_name
# Track if we need to delete an old long-named NFO file
old_nfo_to_delete = None
try: try:
# First, check for existing long-named NFO files that need migration # Check for existing NFO file at target location
existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num) source_nfo_path = nfo_path if nfo_path.exists() else None
# Prioritize long-named file for migration, otherwise use standard file
source_nfo_path = existing_long_nfo if existing_long_nfo else nfo_path if nfo_path.exists() else None
if source_nfo_path: if source_nfo_path:
try: try:
@@ -634,20 +565,7 @@ class NFOManager:
if episode.tag != "episodedetails": if episode.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>") raise ValueError("Root element is not <episodedetails>")
# If we're migrating from a long-named file, mark it for deletion print(f"📝 Updating existing NFO: {nfo_path.name}")
if existing_long_nfo and source_nfo_path == existing_long_nfo:
old_nfo_to_delete = existing_long_nfo
print(f"📦 Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}")
# Show what content fields are being preserved
content_fields = ["title", "plot", "runtime", "premiered"]
preserved_content = []
for field in content_fields:
elem = episode.find(field)
if elem is not None and elem.text:
preserved_content.append(field)
if preserved_content:
print(f" 📄 Preserving content: {', '.join(preserved_content)}")
# Preserve existing content fields and store NFOGuard-managed fields for re-adding # Preserve existing content fields and store NFOGuard-managed fields for re-adding
preserved_values = {} preserved_values = {}
@@ -696,29 +614,6 @@ class NFOManager:
runtime_elem = ET.SubElement(episode, "runtime") runtime_elem = ET.SubElement(episode, "runtime")
runtime_elem.text = str(enhanced_metadata["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 # Add NFOGuard fields at the bottom
# Basic episode info at the end # Basic episode info at the end
@@ -742,28 +637,28 @@ class NFOManager:
lockdata = ET.SubElement(episode, "lockdata") lockdata = ET.SubElement(episode, "lockdata")
lockdata.text = "true" lockdata.text = "true"
# Add NFOGuard comment at the bottom with other NFOGuard fields # Add NFOGuard comment at the beginning
nfoguard_comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ") comment_text = f" Created by {self.manager_brand} - Source: {source} "
episode.append(nfoguard_comment)
# Write file with proper formatting # Write file with proper formatting
tree = ET.ElementTree(episode) tree = ET.ElementTree(episode)
ET.indent(tree, space=" ", level=0) ET.indent(tree, space=" ", level=0)
# Write to file normally (ET will handle the comment properly) # Write to string first to add comment
tree.write(nfo_path, encoding='utf-8', xml_declaration=True) xml_str = ET.tostring(episode, encoding='unicode')
# Add XML declaration and comment
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
# Write to file
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(full_xml)
print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
# Clean up old long-named NFO file if we migrated from it # NFO file created/updated successfully
if old_nfo_to_delete and old_nfo_to_delete.exists(): pass
try:
old_nfo_to_delete.unlink()
print(f"🗑️ Cleaned up old NFO file: {old_nfo_to_delete.name}")
except Exception as cleanup_error:
print(f"⚠️ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}")
except Exception as e: except Exception as e:
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}") print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
@@ -803,4 +698,3 @@ class NFOManager:
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}") print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
else: else:
print(f"⚠️ No video files found to update in {movie_dir.name}") print(f"⚠️ No video files found to update in {movie_dir.name}")
+83 -189
View File
@@ -380,28 +380,39 @@ class TVProcessor:
# Save to database # Save to database
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
# Create season/tvshow NFOs # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
if config.manage_nfo: pass
seasons_processed = set()
for (season, episode) in disk_episodes.keys():
if season not in seasons_processed:
season_dir = series_path / config.tv_season_dir_format.format(season=season)
self.nfo_manager.create_season_nfo(season_dir, season)
seasons_processed.add(season)
# 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 processing TV series: {series_path.name}") _log("INFO", f"Completed processing TV series: {series_path.name}")
def _extract_series_title_from_path(self, series_path: Path) -> Optional[str]:
"""Extract series title from directory path, removing year and IMDb ID"""
name = series_path.name
# Remove IMDb ID part: [imdb-ttXXXXXX] or [ttXXXXXX]
name = re.sub(r'\s*\[imdb-?tt\d+\]', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*\[tt\d+\]', '', name, flags=re.IGNORECASE)
# Remove year in parentheses: (YYYY)
name = re.sub(r'\s*\(\d{4}\)', '', name)
# Clean up extra spaces
name = re.sub(r'\s+', ' ', name).strip()
return name if name else None
def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]: def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all episode video files on disk""" """Find all episode video files on disk"""
disk_episodes = {} disk_episodes = {}
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
_log("DEBUG", f"Scanning for episodes in: {series_path}")
_log("DEBUG", f"Looking for season dirs with pattern: '{config.tv_season_dir_pattern}'")
for season_dir in series_path.iterdir(): for season_dir in series_path.iterdir():
_log("DEBUG", f"Checking: {season_dir.name} (is_dir: {season_dir.is_dir()})")
if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)): if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)):
_log("DEBUG", f"Skipping {season_dir.name} - doesn't match season pattern")
continue continue
try: try:
@@ -440,28 +451,11 @@ class TVProcessor:
if key in disk_episodes: # Only use cached data for episodes we have if key in disk_episodes: # Only use cached data for episodes we have
episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"]) episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"])
# Check for existing NFO files (including long-named ones) for migration # Find missing episodes
nfo_episodes_found = 0
for (season_num, episode_num) in disk_episodes.keys():
if (season_num, episode_num) not in episode_dates:
# Check if this episode has an existing NFO file that needs migration
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
existing_nfo = self.nfo_manager.find_existing_episode_nfo(season_dir, season_num, episode_num)
if existing_nfo:
# Force processing of this episode for NFO migration
episode_dates[(season_num, episode_num)] = (None, None, "nfo_migration_required")
nfo_episodes_found += 1
if nfo_episodes_found > 0:
_log("INFO", f"Found {nfo_episodes_found} episodes with existing NFO files requiring migration")
# Find missing episodes (not in cache and no existing NFO)
cached_keys = set(episode_dates.keys()) cached_keys = set(episode_dates.keys())
missing_keys = set(disk_episodes.keys()) - cached_keys missing_keys = set(disk_episodes.keys()) - cached_keys
if not missing_keys: if not missing_keys:
if nfo_episodes_found == 0:
_log("INFO", "All episodes found in cache") _log("INFO", "All episodes found in cache")
return episode_dates return episode_dates
@@ -470,6 +464,15 @@ class TVProcessor:
# Query Sonarr for missing episodes # Query Sonarr for missing episodes
if self.sonarr.enabled: if self.sonarr.enabled:
series = self.sonarr.series_by_imdb(imdb_id) series = self.sonarr.series_by_imdb(imdb_id)
if not series:
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
series = self.sonarr.series_by_imdb_direct(imdb_id)
if not series:
# Fallback: try searching by series title extracted from path
series_title = self._extract_series_title_from_path(series_path)
if series_title:
_log("DEBUG", f"Trying title search for: {series_title}")
series = self.sonarr.series_by_title(series_title)
if series: if series:
series_id = series.get("id") series_id = series.get("id")
if series_id: if series_id:
@@ -568,7 +571,7 @@ class TVProcessor:
for (season, episode), (aired, dateadded, source) in episode_dates.items(): for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in disk_episodes: if (season, episode) in disk_episodes:
# Get enhanced episode metadata # Get enhanced episode metadata
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) enhanced_metadata = self._get_episode_metadata(series_metadata, season, episode) if series_metadata else None
# Create NFO # Create NFO
if config.manage_nfo: if config.manage_nfo:
@@ -587,12 +590,8 @@ class TVProcessor:
# Save to database # Save to database
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True) self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
# Create season NFO # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
if config.manage_nfo: pass
self.nfo_manager.create_season_nfo(season_path, 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 processing season {season_num}") _log("INFO", f"Completed processing season {season_num}")
@@ -623,21 +622,6 @@ class TVProcessor:
source = nfo_data["source"] source = nfo_data["source"]
aired = nfo_data.get("aired") 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) # Update file mtime if enabled (NFO is already correct)
if config.fix_dir_mtimes and dateadded: if config.fix_dir_mtimes and dateadded:
self.nfo_manager.set_file_mtime(episode_file, dateadded) self.nfo_manager.set_file_mtime(episode_file, dateadded)
@@ -654,14 +638,12 @@ class TVProcessor:
source = existing_episode["source"] source = existing_episode["source"]
aired = existing_episode.get("aired") aired = existing_episode.get("aired")
# Create NFO with existing data, but try to add title from filename if missing # Create NFO with existing data (no enhanced metadata needed)
if config.manage_nfo and dateadded: 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( self.nfo_manager.create_episode_nfo(
season_path, season_path,
season_num, episode_num, aired, dateadded, source, config.lock_metadata, season_num, episode_num, aired, dateadded, source, config.lock_metadata,
enhanced_metadata # Include filename-extracted title if available None # No enhanced metadata for database-cached episodes
) )
# Update file mtime if enabled # Update file mtime if enabled
@@ -676,13 +658,13 @@ class TVProcessor:
# Get enhanced metadata from Sonarr # Get enhanced metadata from Sonarr
series_metadata = self._get_sonarr_series_metadata(imdb_id) series_metadata = self._get_sonarr_series_metadata(imdb_id)
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) enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None
# Get episode date # Get episode date
aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata) aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata)
# Create NFO # Create NFO (always create if manage_nfo is enabled, even without dateadded)
if config.manage_nfo and dateadded: if config.manage_nfo:
self.nfo_manager.create_episode_nfo( self.nfo_manager.create_episode_nfo(
season_path, season_path,
season_num, episode_num, aired, dateadded, source, config.lock_metadata, season_num, episode_num, aired, dateadded, source, config.lock_metadata,
@@ -731,7 +713,6 @@ class TVProcessor:
return None return None
def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]: def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""Get enhanced series metadata from Sonarr API""" """Get enhanced series metadata from Sonarr API"""
try: try:
@@ -739,6 +720,9 @@ class TVProcessor:
return None return None
series = self.sonarr.series_by_imdb(imdb_id) series = self.sonarr.series_by_imdb(imdb_id)
if not series:
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
series = self.sonarr.series_by_imdb_direct(imdb_id)
if not series: if not series:
_log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}") _log("DEBUG", f"No Sonarr series found for IMDb {imdb_id}")
return None return None
@@ -756,97 +740,20 @@ class TVProcessor:
_log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}") _log("ERROR", f"Error getting Sonarr metadata for {imdb_id}: {e}")
return None 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]]: 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 with filename fallback for titles""" """Extract specific episode metadata from series data"""
_log("INFO", f"🔍 _get_episode_metadata called for S{season_num:02d}E{episode_num:02d}, season_dir: {season_dir}") if not series_metadata or "episodes" not in series_metadata:
metadata = { return None
"title": None,
"overview": None,
"runtime": None,
"ratings": {}
}
# Try to get metadata from Sonarr first
if series_metadata and "episodes" in series_metadata:
for episode in series_metadata["episodes"]: for episode in series_metadata["episodes"]:
if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num: if episode.get("seasonNumber") == season_num and episode.get("episodeNumber") == episode_num:
metadata.update({ return {
"title": episode.get("title"), "title": episode.get("title"),
"overview": episode.get("overview"), "overview": episode.get("overview"),
"runtime": episode.get("runtime"), "runtime": episode.get("runtime"),
"ratings": episode.get("ratings", {}) "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 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]]: 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]]:
@@ -880,6 +787,14 @@ class TVProcessor:
if self.sonarr.enabled: if self.sonarr.enabled:
try: try:
series = self.sonarr.series_by_imdb(imdb_id) series = self.sonarr.series_by_imdb(imdb_id)
if not series:
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
series = self.sonarr.series_by_imdb_direct(imdb_id)
if not series:
# Try title search as fallback for IMDb mismatches
_log("DEBUG", f"Trying title search fallback for IMDb: {imdb_id}")
# We don't have series_path here, so just try the imdb_id as a title search
# This is less ideal but better than nothing
if series: if series:
episodes = self.sonarr.episodes_for_series(series["id"]) episodes = self.sonarr.episodes_for_series(series["id"])
for ep in episodes: for ep in episodes:
@@ -963,7 +878,7 @@ class TVProcessor:
# Get episode date information - webhook processing prioritizes existing DB entries # 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}") _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) 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) enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num) if series_metadata else None
# Create NFO # Create NFO
if config.manage_nfo: if config.manage_nfo:
@@ -990,20 +905,8 @@ class TVProcessor:
episodes_processed += 1 episodes_processed += 1
# Create season/tvshow NFOs if any episodes were processed # Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
if episodes_processed > 0 and config.manage_nfo: pass
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") _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed")
@@ -1047,6 +950,9 @@ class TVProcessor:
if not aired and self.sonarr.enabled: if not aired and self.sonarr.enabled:
try: try:
series = self.sonarr.series_by_imdb(imdb_id) series = self.sonarr.series_by_imdb(imdb_id)
if not series:
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
series = self.sonarr.series_by_imdb_direct(imdb_id)
if series: if series:
episodes = self.sonarr.episodes_for_series(series["id"]) episodes = self.sonarr.episodes_for_series(series["id"])
for ep in episodes: for ep in episodes:
@@ -1562,13 +1468,12 @@ class MovieProcessor:
class WebhookBatcher: class WebhookBatcher:
"""Batches webhook events to avoid processing storms""" """Batches webhook events to avoid processing storms"""
def __init__(self, nfo_manager: NFOManager): def __init__(self):
self.pending: Dict[str, Dict] = {} self.pending: Dict[str, Dict] = {}
self.timers: Dict[str, threading.Timer] = {} self.timers: Dict[str, threading.Timer] = {}
self.processing: Set[str] = set() self.processing: Set[str] = set()
self.lock = threading.Lock() self.lock = threading.Lock()
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent) 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): def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
"""Add webhook to batch queue""" """Add webhook to batch queue"""
@@ -1622,23 +1527,11 @@ class WebhookBatcher:
# CRITICAL: Validate that the path contains the expected IMDb ID for movies # CRITICAL: Validate that the path contains the expected IMDb ID for movies
if media_type == 'movie': if media_type == 'movie':
expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
# Use comprehensive IMDb detection (directory, filenames, NFO content) if expected_imdb not in path_str.lower():
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"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") _log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return return
_log("DEBUG", f"Batch validation passed: Expected IMDb {expected_imdb} matches detected IMDb {detected_imdb}") _log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path")
# Process based on media type # Process based on media type
if media_type == 'tv': if media_type == 'tv':
@@ -1721,7 +1614,7 @@ nfo_manager = NFOManager(config.manager_brand, config.debug)
path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper path_mapper = PathMapper(config) # FIXED: Pass config to PathMapper
tv_processor = TVProcessor(db, nfo_manager, path_mapper) tv_processor = TVProcessor(db, nfo_manager, path_mapper)
movie_processor = MovieProcessor(db, nfo_manager, path_mapper) movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
batcher = WebhookBatcher(nfo_manager) batcher = WebhookBatcher()
# --------------------------- # ---------------------------
# Webhook Handlers # Webhook Handlers
@@ -1834,18 +1727,9 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks):
_log("ERROR", f"This prevents processing wrong movies due to path mapping issues") _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}"} return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"}
# Verify the path contains the expected IMDb ID using comprehensive detection # Verify the path contains the expected IMDb ID
detected_imdb = nfo_manager.find_movie_imdb_id(Path(container_path)) if imdb_id not in container_path.lower():
imdb_match = False _log("WARNING", f"IMDb ID {imdb_id} not found in container path {container_path}")
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 # Create movie-specific webhook data with proper path validation
movie_webhook_data = { movie_webhook_data = {
@@ -2193,9 +2077,19 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
except Exception as e: except Exception as e:
_log("ERROR", f"Failed processing episode {scan_path}: {e}") _log("ERROR", f"Failed processing episode {scan_path}: {e}")
else: else:
# Full series processing # Check if this path itself is a series (has IMDb ID in the directory name)
if nfo_manager.parse_imdb_from_path(scan_path):
try:
tv_processor.process_series(scan_path)
except Exception as e:
_log("ERROR", f"Failed processing TV series {scan_path}: {e}")
else:
# Full series processing - scan subdirectories
for item in scan_path.iterdir(): for item in scan_path.iterdir():
if item.is_dir() and nfo_manager.parse_imdb_from_path(item): if (item.is_dir() and
not item.name.lower().startswith('season') and
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
nfo_manager.parse_imdb_from_path(item)):
try: try:
tv_processor.process_series(item) tv_processor.process_series(item)
except Exception as e: except Exception as e:
+380
View File
@@ -0,0 +1,380 @@
#!/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:
# 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 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}")