This commit is contained in:
+416
-24
@@ -4,6 +4,7 @@ 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
|
||||
@@ -63,30 +64,421 @@ class TVProcessor:
|
||||
|
||||
return None
|
||||
|
||||
# NOTE: This is a placeholder extraction - the full 763-line class implementation
|
||||
# needs to be copied from the original nfoguard.py file (lines 299-1061)
|
||||
#
|
||||
# Key methods that need to be extracted include:
|
||||
# - process_series()
|
||||
# - process_webhook_episodes()
|
||||
# - process_season()
|
||||
# - _process_episode()
|
||||
# - _gather_episode_dates()
|
||||
# - _extract_title_from_filename()
|
||||
# - _get_episode_metadata()
|
||||
# - Various helper methods for date handling and processing
|
||||
#
|
||||
# This placeholder allows the import structure to work while we continue
|
||||
# the refactoring process.
|
||||
|
||||
def process_series(self, series_path: Path) -> None:
|
||||
"""Process a TV series directory - PLACEHOLDER"""
|
||||
_log("INFO", f"Processing TV series: {series_path}")
|
||||
# Full implementation needs to be copied from original file
|
||||
pass
|
||||
"""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 NFO
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
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")
|
||||
|
||||
for season_dir in series_path.iterdir():
|
||||
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
|
||||
|
||||
for video_file in season_dir.iterdir():
|
||||
if video_file.is_file() and video_file.suffix.lower() in video_exts:
|
||||
match = re.search(r"S(\d{2})E(\d{2})", video_file.name, re.IGNORECASE)
|
||||
if match:
|
||||
file_season, file_episode = int(match.group(1)), int(match.group(2))
|
||||
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 (including long-named ones) for migration
|
||||
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())
|
||||
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 - PLACEHOLDER"""
|
||||
_log("INFO", f"Processing {len(episodes_data)} episodes for series: {series_path}")
|
||||
# Full implementation needs to be copied from original file
|
||||
pass
|
||||
"""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"
|
||||
Reference in New Issue
Block a user