feat: Complete clean TV NFO processing implementation v2.0.0
Local Docker Build (Dev) / build-dev (push) Successful in 17s
Local Docker Build (Dev) / build-dev (push) Successful in 17s
- New EpisodeNFOManager class with video filename matching - NFO files now match video filenames instead of S01E01.nfo format - Clean TVSeriesProcessor with proper Sonarr API integration - Proper date lookup: database -> Sonarr import history -> airdate fallback - Updated manual scan endpoint to use new clean processor - Removed all migration logic and nfo_migration_required sources This is a complete rewrite of TV episode processing with focus on: 1. NFO filenames matching video files 2. Proper dateadded field population from Sonarr 3. Clean separation of concerns 4. Robust fallback logic
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
#!/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():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
for video_file in season_dir.iterdir():
|
||||||
|
if (video_file.is_file() and
|
||||||
|
video_file.suffix.lower() in video_extensions):
|
||||||
|
|
||||||
|
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
+14
-3
@@ -30,6 +30,7 @@ from core.path_mapper import PathMapper
|
|||||||
from clients.radarr_client import RadarrClient
|
from clients.radarr_client import RadarrClient
|
||||||
from clients.sonarr_client import SonarrClient
|
from clients.sonarr_client import SonarrClient
|
||||||
from clients.external_clients import ExternalClientManager
|
from clients.external_clients import ExternalClientManager
|
||||||
|
from processors.tv_series_processor import TVSeriesProcessor
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Configuration & Logging
|
# Configuration & Logging
|
||||||
@@ -1725,7 +1726,17 @@ start_time = datetime.now(timezone.utc)
|
|||||||
db = NFOGuardDatabase(config.db_path)
|
db = NFOGuardDatabase(config.db_path)
|
||||||
nfo_manager = NFOManager(config.manager_brand, config.debug)
|
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)
|
|
||||||
|
# Initialize Sonarr client
|
||||||
|
sonarr = SonarrClient(
|
||||||
|
base_url=config.sonarr_url,
|
||||||
|
api_key=config.sonarr_api_key,
|
||||||
|
webhook_secret=config.sonarr_webhook_secret,
|
||||||
|
enabled=config.sonarr_enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use new clean TV processor
|
||||||
|
tv_processor = TVSeriesProcessor(db, nfo_manager, path_mapper, sonarr)
|
||||||
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
|
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
|
||||||
batcher = WebhookBatcher(nfo_manager)
|
batcher = WebhookBatcher(nfo_manager)
|
||||||
|
|
||||||
@@ -2199,11 +2210,11 @@ 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
|
# Full series processing with new clean processor
|
||||||
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 nfo_manager.parse_imdb_from_path(item):
|
||||||
try:
|
try:
|
||||||
tv_processor.process_series(item)
|
tv_processor.process_series_manual_scan(item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("ERROR", f"Failed processing TV series {item}: {e}")
|
_log("ERROR", f"Failed processing TV series {item}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
#!/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 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
|
||||||
|
|
||||||
|
def process_series_manual_scan(self, series_path: Path) -> bool:
|
||||||
|
"""Process a TV series during manual scan"""
|
||||||
|
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||||
|
|
||||||
|
# Extract IMDb ID
|
||||||
|
imdb_id = self._extract_imdb_id(series_path)
|
||||||
|
if not imdb_id:
|
||||||
|
_log("ERROR", f"No IMDb ID found for series: {series_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Find all episodes on disk
|
||||||
|
episodes_on_disk = self._find_episodes_on_disk(series_path)
|
||||||
|
if not episodes_on_disk:
|
||||||
|
_log("WARNING", f"No episodes found on disk for: {series_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_log("INFO", f"Found {len(episodes_on_disk)} episodes on disk")
|
||||||
|
|
||||||
|
# Process each episode
|
||||||
|
episodes_processed = 0
|
||||||
|
for (season_num, episode_num), video_files in episodes_on_disk.items():
|
||||||
|
if self._process_episode_manual_scan(series_path, imdb_id, season_num, episode_num):
|
||||||
|
episodes_processed += 1
|
||||||
|
|
||||||
|
# Create series-level NFOs if any episodes were processed
|
||||||
|
if episodes_processed > 0:
|
||||||
|
self._create_series_nfos(series_path, imdb_id)
|
||||||
|
|
||||||
|
_log("INFO", f"Completed processing series: {series_path.name} ({episodes_processed} episodes)")
|
||||||
|
return episodes_processed > 0
|
||||||
|
|
||||||
|
def process_episode_webhook(self, webhook_data: Dict[str, Any]) -> bool:
|
||||||
|
"""Process a single episode from Sonarr webhook"""
|
||||||
|
# TODO: Parse webhook data and extract episode info
|
||||||
|
# This will be implemented when we add webhook support
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _extract_imdb_id(self, series_path: Path) -> Optional[str]:
|
||||||
|
"""Extract IMDb ID from series directory or files"""
|
||||||
|
# Try directory name first
|
||||||
|
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try tvshow.nfo if it exists
|
||||||
|
tvshow_nfo = series_path / "tvshow.nfo"
|
||||||
|
if tvshow_nfo.exists():
|
||||||
|
imdb_id = self.nfo_manager.parse_imdb_from_nfo(tvshow_nfo)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try any existing episode NFO files
|
||||||
|
for season_dir in series_path.iterdir():
|
||||||
|
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||||
|
for nfo_file in season_dir.glob("*.nfo"):
|
||||||
|
imdb_id = self.nfo_manager.parse_imdb_from_nfo(nfo_file)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_season_directory(self, dirname: str) -> bool:
|
||||||
|
"""Check if directory name matches season pattern"""
|
||||||
|
return bool(re.match(r'^[Ss]eason\s+\d+$', dirname, re.IGNORECASE))
|
||||||
|
|
||||||
|
def _find_episodes_on_disk(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||||
|
"""Find all episodes on disk, grouped by (season, episode)"""
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
for season_dir in series_path.iterdir():
|
||||||
|
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||||
|
season_num = self._extract_season_number(season_dir.name)
|
||||||
|
if season_num is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find video files in this season
|
||||||
|
season_episodes = self.episode_nfo_manager.find_video_files_for_season(season_dir)
|
||||||
|
|
||||||
|
# Add season directory info to episodes
|
||||||
|
for (s_num, e_num), video_files in season_episodes.items():
|
||||||
|
if s_num == season_num: # Verify season matches directory
|
||||||
|
episodes[(s_num, e_num)] = video_files
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
def _extract_season_number(self, dirname: str) -> Optional[int]:
|
||||||
|
"""Extract season number from directory name"""
|
||||||
|
match = re.search(r'[Ss]eason\s+(\d+)', dirname, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _process_episode_manual_scan(self, series_path: Path, imdb_id: str,
|
||||||
|
season_num: int, episode_num: int) -> bool:
|
||||||
|
"""Process a single episode during manual scan"""
|
||||||
|
season_dir = series_path / f"Season {season_num:02d}"
|
||||||
|
if not season_dir.exists():
|
||||||
|
# Try alternate format
|
||||||
|
season_dir = series_path / f"Season {season_num}"
|
||||||
|
if not season_dir.exists():
|
||||||
|
_log("ERROR", f"Season directory not found for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_log("DEBUG", f"Processing episode S{season_num:02d}E{episode_num:02d}")
|
||||||
|
|
||||||
|
# Step 1: Check for existing NFOGuard data
|
||||||
|
existing_nfo = self.episode_nfo_manager.find_nfo_for_episode(season_dir, season_num, episode_num)
|
||||||
|
if existing_nfo:
|
||||||
|
nfo_data = self.episode_nfo_manager.extract_nfoguard_data(existing_nfo)
|
||||||
|
if nfo_data:
|
||||||
|
# Verify against database
|
||||||
|
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||||
|
if db_data and db_data.get("dateadded") == nfo_data.get("dateadded"):
|
||||||
|
_log("DEBUG", f"Episode S{season_num:02d}E{episode_num:02d} already up to date")
|
||||||
|
# Still migrate filename if needed
|
||||||
|
self.episode_nfo_manager.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Step 2: Check database
|
||||||
|
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||||
|
if db_data and db_data.get("dateadded"):
|
||||||
|
_log("DEBUG", f"Using database data for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
aired = db_data.get("aired")
|
||||||
|
dateadded = db_data.get("dateadded")
|
||||||
|
source = db_data.get("source", "database")
|
||||||
|
else:
|
||||||
|
# Step 3: Query Sonarr for episode data
|
||||||
|
aired, dateadded, source = self._get_episode_dates_from_sonarr(imdb_id, season_num, episode_num)
|
||||||
|
|
||||||
|
# Step 4: Create/update NFO and database
|
||||||
|
if dateadded:
|
||||||
|
# Get episode metadata for title/plot
|
||||||
|
title, plot = self._get_episode_metadata_from_sonarr(imdb_id, season_num, episode_num)
|
||||||
|
|
||||||
|
# Create/update NFO with video filename
|
||||||
|
success = self.episode_nfo_manager.create_episode_nfo(
|
||||||
|
season_dir, season_num, episode_num, aired, dateadded, source, title, plot
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Update database
|
||||||
|
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
_log("WARNING", f"Could not get dates for episode S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
|
||||||
|
"""Get episode dates from Sonarr API with proper fallback"""
|
||||||
|
aired = None
|
||||||
|
dateadded = None
|
||||||
|
source = "no_data_found"
|
||||||
|
|
||||||
|
if not self.sonarr.enabled:
|
||||||
|
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find series in Sonarr
|
||||||
|
series = self.sonarr.series_by_imdb(imdb_id)
|
||||||
|
if not series:
|
||||||
|
_log("WARNING", f"Series not found in Sonarr for IMDb: {imdb_id}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
# Get episodes for series
|
||||||
|
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||||
|
target_episode = None
|
||||||
|
|
||||||
|
for episode in episodes:
|
||||||
|
if (episode.get("seasonNumber") == season_num and
|
||||||
|
episode.get("episodeNumber") == episode_num):
|
||||||
|
target_episode = episode
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_episode:
|
||||||
|
_log("WARNING", f"Episode S{season_num:02d}E{episode_num:02d} not found in Sonarr")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
# Get airdate
|
||||||
|
aired = target_episode.get("airDateUtc")
|
||||||
|
|
||||||
|
# Try to get import history
|
||||||
|
episode_id = target_episode.get("id")
|
||||||
|
if episode_id:
|
||||||
|
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||||
|
if import_date:
|
||||||
|
dateadded = convert_utc_to_local(import_date)
|
||||||
|
source = "sonarr:history.import"
|
||||||
|
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
# Fallback to airdate if no import history
|
||||||
|
if aired:
|
||||||
|
dateadded = convert_utc_to_local(aired)
|
||||||
|
source = "sonarr:episode.airDateUtc"
|
||||||
|
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Sonarr API error for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||||
|
|
||||||
|
_log("ERROR", f"Could not get any date information for S{season_num:02d}E{episode_num:02d}")
|
||||||
|
return aired, dateadded, source
|
||||||
|
|
||||||
|
def _get_episode_metadata_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Get episode title and plot from Sonarr"""
|
||||||
|
if not self.sonarr.enabled:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
series = self.sonarr.series_by_imdb(imdb_id)
|
||||||
|
if series:
|
||||||
|
episodes = self.sonarr.episodes_for_series(series["id"])
|
||||||
|
for episode in episodes:
|
||||||
|
if (episode.get("seasonNumber") == season_num and
|
||||||
|
episode.get("episodeNumber") == episode_num):
|
||||||
|
title = episode.get("title")
|
||||||
|
plot = episode.get("overview")
|
||||||
|
return title, plot
|
||||||
|
except Exception as e:
|
||||||
|
_log("DEBUG", f"Could not get metadata for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _create_series_nfos(self, series_path: Path, imdb_id: str):
|
||||||
|
"""Create tvshow.nfo and season.nfo files"""
|
||||||
|
# Create tvshow.nfo
|
||||||
|
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
|
||||||
|
|
||||||
|
# Create season.nfo for each season directory
|
||||||
|
for season_dir in series_path.iterdir():
|
||||||
|
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
|
||||||
|
season_num = self._extract_season_number(season_dir.name)
|
||||||
|
if season_num is not None:
|
||||||
|
self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||||
Reference in New Issue
Block a user