b431107abd
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
264 lines
12 KiB
Python
264 lines
12 KiB
Python
#!/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) |