Files
nfoguard/processors/tv_processor.py
T
sbcrumb 4980d1ae04
Local Docker Build (Dev) / build-dev (push) Successful in 29s
feat: Preserve TV episode NFO filenames matching video files
Instead of migrating long NFO names to standardized S01E01.nfo format,
now preserves original filenames that match video files while still
populating NFOGuard metadata (dateadded, aired, season, episode).

Changes:
- Added update_episode_nfo_preserving_name() method to NFOManager
- Added find_episode_nfo_matching_video() to locate NFO files by video name pattern
- Modified TVProcessor to update existing NFOs in-place rather than migrate names
- Episodes with existing NFOs now get "nfo_update_required" source for special handling

This maintains the user's preferred naming convention where NFO files
match their corresponding video files exactly.
2025-09-26 08:16:42 -04:00

509 lines
23 KiB
Python

#!/usr/bin/env python3
"""
TV Show processing logic for NFOGuard
"""
import os
import glob
import re
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timezone, timedelta
# Import core components
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from core.logging import _log
from core.fs_cache import fs_cache, parse_episode_from_filename, extract_imdb_from_path
from clients.sonarr_client import SonarrClient
from clients.external_clients import ExternalClientManager
from config.settings import config
class TVProcessor:
"""Handles TV series processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
self.db = db
self.nfo_manager = nfo_manager
self.path_mapper = path_mapper
self.sonarr = SonarrClient(
os.environ.get("SONARR_URL", ""),
os.environ.get("SONARR_API_KEY", "")
)
self.external_clients = ExternalClientManager()
def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]:
"""Find series directory path"""
# Try webhook path first
if sonarr_path:
container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path)
path_obj = Path(container_path)
if path_obj.exists():
return path_obj
# Search by IMDb ID or title
for media_path in config.tv_paths:
if not media_path.exists():
continue
# Search by IMDb ID
if imdb_id:
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
matches = glob.glob(pattern)
if matches:
return Path(matches[0])
# Search by title
if series_title:
title_clean = series_title.lower().replace(" ", "").replace("-", "")
for item in media_path.iterdir():
if item.is_dir() and "[imdb-" in item.name.lower():
item_clean = item.name.lower().replace(" ", "").replace("-", "")
if title_clean in item_clean:
return item
return None
def process_series(self, series_path: Path) -> None:
"""Process a TV series directory"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
return
_log("INFO", f"Processing TV series: {series_path.name}")
# Update database
self.db.upsert_series(imdb_id, str(series_path))
# Find video files
disk_episodes = self._find_disk_episodes(series_path)
_log("INFO", f"Found {len(disk_episodes)} episodes on disk")
# Get episode dates
episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes)
# Process episodes
for (season, episode), (aired, dateadded, source) in episode_dates.items():
if (season, episode) in disk_episodes:
# Create or update NFO
if config.manage_nfo:
season_dir = series_path / config.tv_season_dir_format.format(season=season)
# Check if this is an existing NFO that needs updating while preserving name
if source == "nfo_update_required":
# Find the existing NFO file and update it in place
existing_nfo = self.nfo_manager.find_episode_nfo_matching_video(season_dir, season, episode)
if existing_nfo:
# Update existing NFO while preserving its filename
self.nfo_manager.update_episode_nfo_preserving_name(
existing_nfo, season, episode, aired, dateadded, "file_update"
)
else:
# Fallback to standard creation if NFO not found
self.nfo_manager.create_episode_nfo(
season_dir,
season, episode, aired, dateadded, source, config.lock_metadata
)
else:
# Standard NFO creation for new episodes
self.nfo_manager.create_episode_nfo(
season_dir,
season, episode, aired, dateadded, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
video_files = disk_episodes[(season, episode)]
for video_file in video_files:
self.nfo_manager.set_file_mtime(video_file, dateadded)
# Save to database
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
# Create season/tvshow NFOs
if config.manage_nfo:
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}")
def _find_disk_episodes(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all episode video files on disk"""
disk_episodes = {}
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
# Use cached directory listing
season_dirs = fs_cache.get_directory_contents(series_path)
for season_dir in season_dirs:
if not (season_dir.is_dir() and season_dir.name.lower().startswith(config.tv_season_dir_pattern)):
continue
try:
# Extract season number from directory name
# Handle formats like "Season 01", "S01", "Season01", etc.
dir_name = season_dir.name.lower()
if config.tv_season_dir_pattern in dir_name:
# Extract everything after the pattern
season_part = dir_name[len(config.tv_season_dir_pattern):].strip()
else:
continue
season_num = int(season_part)
except (ValueError, IndexError):
continue
# Use cached video file discovery
video_files = fs_cache.find_video_files(season_dir)
for video_file in video_files:
# Use cached episode parsing
episode_info = parse_episode_from_filename(video_file.name)
if episode_info:
file_season, file_episode = episode_info
key = (season_num, file_episode) # Use directory season number
if key not in disk_episodes:
disk_episodes[key] = []
disk_episodes[key].append(video_file)
return disk_episodes
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict) -> Dict:
"""Gather episode dates from various sources"""
episode_dates = {}
# Check cache first
cached_episodes = self.db.get_series_episodes(imdb_id, has_video_file_only=True)
for ep in cached_episodes:
key = (ep["season"], ep["episode"])
if key in disk_episodes: # Only use cached data for episodes we have
episode_dates[key] = (ep["aired"], ep["dateadded"], ep["source"])
# Check for existing NFO files that match video file names (preserve long names)
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 NFO file matching its video file name
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
existing_nfo = self.nfo_manager.find_episode_nfo_matching_video(season_dir, season_num, episode_num)
if existing_nfo:
# Force processing of this episode to update NFO with NFOGuard data
episode_dates[(season_num, episode_num)] = (None, None, "nfo_update_required")
nfo_episodes_found += 1
if nfo_episodes_found > 0:
_log("INFO", f"Found {nfo_episodes_found} episodes with existing NFO files to update (preserving names)")
# Find missing episodes (not in cache and no existing NFO)
cached_keys = set(episode_dates.keys())
missing_keys = set(disk_episodes.keys()) - cached_keys
if not missing_keys:
if nfo_episodes_found == 0:
_log("INFO", "All episodes found in cache")
return episode_dates
_log("INFO", f"Querying APIs for {len(missing_keys)} missing episodes")
# Query Sonarr for missing episodes
if self.sonarr.enabled:
series = self.sonarr.series_by_imdb(imdb_id)
if series:
series_id = series.get("id")
if series_id:
episodes = self.sonarr.episodes_for_series(series_id)
for ep in episodes:
season_num = ep.get("seasonNumber")
episode_num = ep.get("episodeNumber")
if not isinstance(season_num, int) or not isinstance(episode_num, int):
continue
key = (season_num, episode_num)
if key not in missing_keys:
continue
# Get dates
aired = self._parse_date_to_iso(ep.get("airDateUtc"))
dateadded = None
source = "sonarr:episode.airDateUtc"
# Try to get import history
episode_id = ep.get("id")
if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = self._parse_date_to_iso(import_date)
source = "sonarr:history.import"
if not dateadded:
dateadded = aired
if aired or dateadded:
episode_dates[key] = (aired, dateadded, source)
# Fill remaining gaps with external APIs
remaining_keys = missing_keys - set(episode_dates.keys())
if remaining_keys and self.external_clients.tmdb.enabled:
tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id)
if tmdb_movie:
tv_id = tmdb_movie.get("id")
if tv_id:
seasons_needed = set(season for season, episode in remaining_keys)
for season_num in seasons_needed:
tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num)
for ep_num, air_date in tmdb_episodes.items():
key = (season_num, ep_num)
if key in remaining_keys:
aired = self._parse_date_to_iso(air_date)
episode_dates[key] = (aired, aired, "tmdb:air_date")
return episode_dates
def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
"""Parse date string to ISO format"""
if not date_str:
return None
try:
if len(date_str) == 10 and date_str[4] == "-":
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
else:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
return dt.isoformat(timespec="seconds")
except Exception:
return None
def process_webhook_episodes(self, series_path: Path, episodes_data: List[Dict]) -> None:
"""Process specific episodes from webhook data"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
return
_log("INFO", f"Processing {len(episodes_data)} episodes from webhook for series: {series_path.name}")
# Update database
self.db.upsert_series(imdb_id, str(series_path))
# Get enhanced metadata from Sonarr
series_metadata = self._get_sonarr_series_metadata(imdb_id)
for episode_data in episodes_data:
season_num = episode_data.get("seasonNumber")
episode_num = episode_data.get("episodeNumber")
if not isinstance(season_num, int) or not isinstance(episode_num, int):
continue
_log("INFO", f"Processing webhook episode S{season_num:02d}E{episode_num:02d}")
# Find episode file
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
if not season_dir.exists():
_log("WARNING", f"Season directory not found: {season_dir}")
continue
# Find video file for this episode
episode_file = self._find_episode_video_file(season_dir, season_num, episode_num)
if not episode_file:
_log("WARNING", f"Video file not found for S{season_num:02d}E{episode_num:02d}")
continue
# Process this specific episode
self.process_episode_file(series_path, season_dir, episode_file)
def _find_episode_video_file(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find video file for specific episode"""
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
for video_file in season_dir.iterdir():
if (video_file.is_file() and
video_file.suffix.lower() in video_exts and
episode_pattern.upper() in video_file.name.upper()):
return video_file
return None
def _get_sonarr_series_metadata(self, imdb_id: str) -> Optional[Dict]:
"""Get series metadata from Sonarr"""
if not self.sonarr.enabled:
return None
series = self.sonarr.series_by_imdb(imdb_id)
if not series:
return None
series_id = series.get("id")
if not series_id:
return None
# Get episodes for this series
episodes = self.sonarr.episodes_for_series(series_id)
return {
"series": series,
"episodes": {(ep.get("seasonNumber"), ep.get("episodeNumber")): ep for ep in episodes}
}
def _get_episode_metadata(self, series_metadata: Optional[Dict], season_num: int, episode_num: int, season_path: Path) -> Dict:
"""Get enhanced episode metadata"""
metadata = {}
# Try to get title from Sonarr first
if series_metadata and "episodes" in series_metadata:
episode_data = series_metadata["episodes"].get((season_num, episode_num))
if episode_data and episode_data.get("title"):
metadata["title"] = episode_data["title"]
# Fallback to extracting title from filename
if "title" not in metadata:
filename_title = self._extract_title_from_filename(season_path, season_num, episode_num)
if filename_title:
metadata["title"] = filename_title
return metadata
def _extract_title_from_filename(self, season_path: Path, season_num: int, episode_num: int) -> Optional[str]:
"""Extract episode title from video filename"""
episode_file = self._find_episode_video_file(season_path, season_num, episode_num)
if not episode_file:
return None
filename = episode_file.stem
# Pattern: Series.S01E01.Title.Here.RESOLUTION.etc
# Look for everything after S##E## until common quality indicators
episode_pattern = f"S{season_num:02d}E{episode_num:02d}"
# Find the episode marker
episode_index = filename.upper().find(episode_pattern.upper())
if episode_index == -1:
return None
# Extract everything after the episode marker
after_episode = filename[episode_index + len(episode_pattern):]
# Split by common separators
parts = re.split(r'[.\-_\s]+', after_episode)
# Stop at common quality/release indicators
stop_words = {
'720p', '1080p', '4k', '2160p', 'hdtv', 'webrip', 'bluray', 'dvdrip',
'web-dl', 'brrip', 'hdcam', 'hdts', 'cam', 'ts', 'tc', 'r5', 'dvdscr',
'x264', 'x265', 'h264', 'h265', 'xvid', 'divx', 'aac', 'mp3', 'ac3',
'dts', 'flac', 'atmos', 'truehd', 'dd', 'ddp', 'eac3'
}
title_parts = []
for part in parts:
if not part:
continue
if part.lower() in stop_words:
break
# Stop at year patterns (e.g., 2023)
if re.match(r'^\d{4}$', part):
break
title_parts.append(part)
if title_parts:
return ' '.join(title_parts)
return None
def process_episode_file(self, series_path: Path, season_path: Path, episode_file: Path) -> None:
"""Process a single TV episode file"""
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
return
# Parse episode info from filename
episode_info = self._parse_episode_from_filename(episode_file.name)
if not episode_info:
_log("ERROR", f"Could not parse episode info from: {episode_file.name}")
return
season_num, episode_num = episode_info
_log("INFO", f"Processing single episode S{season_num:02d}E{episode_num:02d}: {episode_file.name}")
# Update database
self.db.upsert_series(imdb_id, str(series_path))
# Get enhanced metadata from Sonarr
series_metadata = self._get_sonarr_series_metadata(imdb_id)
enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_path) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_path)
# Get episode date
aired, dateadded, source = self._get_single_episode_date(imdb_id, season_num, episode_num, series_metadata)
# Create NFO
if config.manage_nfo and dateadded:
self.nfo_manager.create_episode_nfo(
season_path,
season_num, episode_num, aired, dateadded, source, config.lock_metadata,
enhanced_metadata
)
# Update file mtime
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.set_file_mtime(episode_file, dateadded)
# Save to database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
_log("INFO", f"Completed processing episode S{season_num:02d}E{episode_num:02d}")
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Parse season and episode numbers from filename"""
match = re.search(r"S(\d{1,2})E(\d{1,2})", filename, re.IGNORECASE)
if match:
return int(match.group(1)), int(match.group(2))
return None
def _get_single_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict]) -> Tuple[Optional[str], Optional[str], str]:
"""Get dates for a single episode"""
# Check database first
existing_episode = self.db.get_episode_date(imdb_id, season_num, episode_num)
if existing_episode and existing_episode.get("dateadded"):
return existing_episode.get("aired"), existing_episode["dateadded"], existing_episode["source"]
# Try Sonarr metadata
if series_metadata and "episodes" in series_metadata:
episode_data = series_metadata["episodes"].get((season_num, episode_num))
if episode_data:
aired = self._parse_date_to_iso(episode_data.get("airDateUtc"))
dateadded = aired # Use air date as fallback
source = "sonarr:episode.airDateUtc"
# Try to get import history
episode_id = episode_data.get("id")
if episode_id and self.sonarr.enabled:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = self._parse_date_to_iso(import_date)
source = "sonarr:history.import"
return aired, dateadded, source
# Fallback to external APIs
if self.external_clients.tmdb.enabled:
tmdb_movie = self.external_clients.tmdb.find_by_imdb(imdb_id)
if tmdb_movie:
tv_id = tmdb_movie.get("id")
if tv_id:
tmdb_episodes = self.external_clients.tmdb.get_tv_season_episodes(tv_id, season_num)
air_date = tmdb_episodes.get(episode_num)
if air_date:
aired = self._parse_date_to_iso(air_date)
return aired, aired, "tmdb:air_date"
return None, None, "no_valid_date_source"