huge: update
Local Docker Build (Dev) / build-dev (push) Successful in 28s

This commit is contained in:
2025-09-25 18:41:37 -04:00
parent 740f912b57
commit 70302837a7
4 changed files with 1047 additions and 85 deletions
+41 -9
View File
@@ -72,9 +72,9 @@ def register_routes(
_log("WARNING", "No series data in Sonarr webhook")
return {"status": "error", "message": "No series data"}
# Process the webhook (implementation would continue from original file)
# This is a placeholder - full implementation needs to be copied from nfoguard.py
return {"status": "queued", "message": "Sonarr webhook queued for processing"}
# Process the webhook using the batcher
await batcher.queue_sonarr_webhook(payload)
return {"status": "queued", "message": f"Sonarr {webhook.eventType} webhook queued for processing"}
except Exception as e:
_log("ERROR", f"Sonarr webhook error: {e}")
@@ -98,9 +98,9 @@ def register_routes(
_log("WARNING", "No movie data in Radarr webhook")
return {"status": "error", "message": "No movie data"}
# Process the webhook (implementation would continue from original file)
# This is a placeholder - full implementation needs to be copied from nfoguard.py
return {"status": "queued", "message": "Radarr webhook queued for processing"}
# Process the webhook using the batcher
await batcher.queue_radarr_webhook(payload)
return {"status": "queued", "message": f"Radarr {webhook.eventType} webhook queued for processing"}
except Exception as e:
_log("ERROR", f"Radarr webhook error: {e}")
@@ -158,16 +158,48 @@ def register_routes(
async def batch_status():
"""Get current batch processing status"""
return {
"message": "Batch status endpoint - implementation needed",
"pending_items": len(getattr(batcher, 'pending', {}))
"pending_items": batcher.get_pending_count(),
"processing_items": batcher.get_processing_count(),
"total_queued": batcher.get_pending_count() + batcher.get_processing_count()
}
@app.post("/manual/scan")
async def manual_scan(background_tasks: BackgroundTasks):
"""Trigger manual library scan"""
try:
# Add the actual manual scan logic here
_log("INFO", "Manual scan triggered via API")
# Add background task to scan all libraries
def scan_libraries():
total_processed = 0
# Scan TV libraries
for tv_path in config.tv_paths:
if tv_path.exists():
_log("INFO", f"Scanning TV library: {tv_path}")
for series_dir in tv_path.iterdir():
if series_dir.is_dir() and "[imdb-" in series_dir.name:
try:
tv_processor.process_series(series_dir)
total_processed += 1
except Exception as e:
_log("ERROR", f"Error processing series {series_dir.name}: {e}")
# Scan Movie libraries
for movie_path in config.movie_paths:
if movie_path.exists():
_log("INFO", f"Scanning movie library: {movie_path}")
for movie_dir in movie_path.iterdir():
if movie_dir.is_dir():
try:
movie_processor.process_movie(movie_dir)
total_processed += 1
except Exception as e:
_log("ERROR", f"Error processing movie {movie_dir.name}: {e}")
_log("INFO", f"Manual scan completed: {total_processed} items processed")
background_tasks.add_task(scan_libraries)
return {"status": "started", "message": "Manual scan initiated"}
except Exception as e:
_log("ERROR", f"Manual scan failed: {e}")
+382 -26
View File
@@ -3,6 +3,7 @@
Movie processing logic for NFOGuard
"""
import os
import glob
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime, timezone
@@ -30,33 +31,388 @@ class MovieProcessor:
)
self.external_clients = ExternalClientManager()
# NOTE: This is a placeholder extraction - the full 500-line class implementation
# needs to be copied from the original nfoguard.py file (lines 1062-1561)
#
# Key methods that need to be extracted include:
# - process_movie()
# - validate_batch_movies()
# - get_movie_dates()
# - _should_query_external_apis()
# - Various helper methods for date handling and processing
#
# This placeholder allows the import structure to work while we continue
# the refactoring process.
def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
"""Find movie directory path"""
# Try webhook path first
if radarr_path:
container_path = self.path_mapper.radarr_path_to_container_path(radarr_path)
path_obj = Path(container_path)
if path_obj.exists():
return path_obj
def process_movie(self, movie_path: Path, imdb_id: str = None) -> bool:
"""Process a single movie - PLACEHOLDER"""
_log("INFO", f"Processing movie: {movie_path}")
# Full implementation needs to be copied from original file
return True
# Search by IMDb ID or title
for media_path in config.movie_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 movie_title:
title_clean = movie_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_movie(self, movie_path: Path, webhook_mode: bool = False) -> None:
"""Process a movie directory"""
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
return
# Handle TMDB ID fallback case
is_tmdb_fallback = imdb_id.startswith("tmdb-")
if is_tmdb_fallback:
_log("INFO", f"Processing movie: {movie_path.name} (TMDB: {imdb_id})")
else:
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
# Update database
self.db.upsert_movie(imdb_id, str(movie_path))
# Check for video files
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir())
if not has_video:
_log("WARNING", f"No video files found in: {movie_path}")
self.db.upsert_movie_dates(imdb_id, None, None, None, False)
return
# TIER 1: Check if NFO file already has NFOGuard data (fastest - no DB or API calls)
nfo_path = movie_path / "movie.nfo"
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
if nfo_data:
_log("INFO", f"🚀 Using existing NFOGuard data from NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
dateadded = nfo_data["dateadded"]
source = nfo_data["source"]
released = nfo_data.get("released")
# Update file mtimes if enabled (NFO is already correct)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-only]")
return
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
if is_tmdb_fallback:
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
if tmdb_nfo_data:
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
dateadded = tmdb_nfo_data["dateadded"]
source = tmdb_nfo_data["source"]
released = tmdb_nfo_data.get("released")
# Create NFO with NFOGuard fields added
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# Save to database
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo-enhanced]")
return
# TIER 2: Check database for existing movie data
existing_movie = self.db.get_movie_dates(imdb_id)
if existing_movie and existing_movie.get("dateadded") and existing_movie.get("source") != "no_valid_date_source":
_log("INFO", f"✅ Using complete database data: {existing_movie['dateadded']} (source: {existing_movie['source']})")
# Still create NFO and update files but skip API queries
dateadded = existing_movie["dateadded"]
source = existing_movie["source"]
released = existing_movie.get("released")
if config.manage_nfo and dateadded:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-only]")
return
# TIER 3: Full processing with API calls (slowest)
_log("DEBUG", f"Movie requires full processing - querying external APIs")
# Get movie dates from various sources
movie_dates = self.get_movie_dates(imdb_id, movie_path, webhook_mode=webhook_mode,
fallback_to_tmdb=(not is_tmdb_fallback))
dateadded = movie_dates.get("dateadded")
released = movie_dates.get("released")
source = movie_dates.get("source", "no_valid_date_source")
# Create NFO
if config.manage_nfo and dateadded:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
# Update file mtimes
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# Save to database
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [full-processing]")
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict]:
"""Extract dates from existing TMDB NFO file"""
if not nfo_path.exists():
return None
try:
with open(nfo_path, 'r', encoding='utf-8') as f:
content = f.read()
# Look for dateadded and premiered elements
dateadded = None
released = None
# Extract dateadded
import re
dateadded_match = re.search(r'<dateadded>([^<]+)</dateadded>', content)
if dateadded_match:
dateadded = dateadded_match.group(1).strip()
# Extract premiered (release date)
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', content)
if premiered_match:
released = premiered_match.group(1).strip()
if dateadded:
return {
"dateadded": dateadded,
"released": released,
"source": "tmdb:nfo.dateadded"
}
except Exception as e:
_log("DEBUG", f"Could not extract dates from TMDB NFO: {e}")
return None
def get_movie_dates(self, imdb_id: str, movie_path: Path = None, webhook_mode: bool = False, fallback_to_tmdb: bool = True) -> Dict[str, Any]:
"""Get movie dates from various sources with priority system"""
# Initialize result
result = {
"dateadded": None,
"released": None,
"source": "no_valid_date_source"
}
# Check if this is a TMDB ID
if imdb_id.startswith("tmdb-"):
return self._get_tmdb_movie_dates(imdb_id, movie_path)
# Priority 1: Radarr import history (most accurate for dateadded)
if self.radarr.enabled and config.movie_priority >= 1:
try:
movie_data = self.radarr.get_movie_by_imdb(imdb_id)
if movie_data:
# Get import history for accurate dateadded
movie_id = movie_data.get("id")
if movie_id:
import_history = self.radarr.get_movie_import_history(movie_id)
if import_history:
result["dateadded"] = self._parse_date_to_iso(import_history)
result["source"] = "radarr:history.import"
_log("INFO", f"Found Radarr import date: {result['dateadded']}")
# Get release date from Radarr
release_date = movie_data.get("digitalRelease") or movie_data.get("physicalRelease") or movie_data.get("inCinemas")
if release_date:
result["released"] = self._parse_date_to_iso(release_date)
# If we have dateadded from import, we're done
if result["dateadded"]:
return result
# Fallback to using dateadded from Radarr API
radarr_dateadded = movie_data.get("added")
if radarr_dateadded:
result["dateadded"] = self._parse_date_to_iso(radarr_dateadded)
result["source"] = "radarr:movie.added"
return result
except Exception as e:
_log("DEBUG", f"Radarr query failed for {imdb_id}: {e}")
# Priority 2: External APIs (TMDB, OMDb, etc.) for release date
if config.movie_priority >= 2 and fallback_to_tmdb:
try:
# Try TMDB
if self.external_clients.tmdb.enabled:
tmdb_data = self.external_clients.tmdb.find_by_imdb(imdb_id)
if tmdb_data:
release_date = tmdb_data.get("release_date")
if release_date:
released_iso = self._parse_date_to_iso(release_date)
result["released"] = released_iso
# Use release date as dateadded fallback
if not result["dateadded"]:
result["dateadded"] = released_iso
result["source"] = "tmdb:release_date"
return result
# Try OMDb as additional fallback
if self.external_clients.omdb.enabled:
omdb_data = self.external_clients.omdb.get_by_imdb(imdb_id)
if omdb_data and omdb_data.get("Released"):
released_date = self._parse_omdb_date(omdb_data["Released"])
if released_date:
result["released"] = released_date
if not result["dateadded"]:
result["dateadded"] = released_date
result["source"] = "omdb:released"
return result
except Exception as e:
_log("DEBUG", f"External API query failed for {imdb_id}: {e}")
# Priority 3: File system dates as absolute fallback
if not result["dateadded"] and movie_path and config.movie_priority >= 3:
try:
# Use directory creation time as last resort
if movie_path.exists():
dir_stat = movie_path.stat()
# Use the earliest of creation or modification time
earliest_time = min(dir_stat.st_ctime, dir_stat.st_mtime)
fs_date = datetime.fromtimestamp(earliest_time, tz=timezone.utc)
result["dateadded"] = fs_date.isoformat(timespec="seconds")
result["source"] = "filesystem:dir.ctime"
_log("DEBUG", f"Using filesystem date as fallback: {result['dateadded']}")
return result
except Exception as e:
_log("DEBUG", f"Filesystem date extraction failed: {e}")
return result
def _get_tmdb_movie_dates(self, tmdb_id: str, movie_path: Path = None) -> Dict[str, Any]:
"""Get movie dates for TMDB-only movies"""
result = {
"dateadded": None,
"released": None,
"source": "no_valid_date_source"
}
# Extract TMDB ID from the string (format: "tmdb-12345")
try:
tmdb_numeric_id = tmdb_id.replace("tmdb-", "")
if self.external_clients.tmdb.enabled:
tmdb_data = self.external_clients.tmdb.get_movie_details(tmdb_numeric_id)
if tmdb_data:
release_date = tmdb_data.get("release_date")
if release_date:
released_iso = self._parse_date_to_iso(release_date)
result["released"] = released_iso
result["dateadded"] = released_iso # Use release date as dateadded
result["source"] = "tmdb:release_date"
return result
except Exception as e:
_log("DEBUG", f"TMDB query failed for {tmdb_id}: {e}")
# Fallback to filesystem date for TMDB movies
if movie_path and movie_path.exists():
try:
dir_stat = movie_path.stat()
earliest_time = min(dir_stat.st_ctime, dir_stat.st_mtime)
fs_date = datetime.fromtimestamp(earliest_time, tz=timezone.utc)
result["dateadded"] = fs_date.isoformat(timespec="seconds")
result["source"] = "filesystem:dir.ctime"
except Exception as e:
_log("DEBUG", f"Filesystem date extraction failed: {e}")
return result
def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
"""Parse date string to ISO format"""
if not date_str:
return None
try:
# Handle different date formats
if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
else: # ISO format with timezone
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
return dt.isoformat(timespec="seconds")
except Exception:
return None
def _parse_omdb_date(self, date_str: str) -> Optional[str]:
"""Parse OMDb date format (e.g., '25 Dec 2020')"""
if not date_str or date_str == "N/A":
return None
try:
# Parse OMDb date format: "25 Dec 2020"
dt = datetime.strptime(date_str, "%d %b %Y").replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds")
except Exception:
return None
def validate_batch_movies(self, movie_paths: List[Path]) -> Dict[str, Any]:
"""Validate batch of movies - PLACEHOLDER"""
_log("INFO", f"Validating {len(movie_paths)} movies")
# Full implementation needs to be copied from original file
return {"processed": 0, "errors": []}
"""Validate and process a batch of movies"""
results = {
"processed": 0,
"errors": [],
"skipped": 0
}
def get_movie_dates(self, imdb_id: str, movie_path: Path = None) -> Dict[str, Any]:
"""Get movie dates from various sources - PLACEHOLDER"""
_log("DEBUG", f"Getting dates for movie {imdb_id}")
# Full implementation needs to be copied from original file
return {"source": "placeholder", "dateadded": None}
_log("INFO", f"Starting batch processing of {len(movie_paths)} movies")
for i, movie_path in enumerate(movie_paths, 1):
try:
_log("INFO", f"Processing movie {i}/{len(movie_paths)}: {movie_path.name}")
# Check if directory exists and has video files
if not movie_path.exists() or not movie_path.is_dir():
results["errors"].append(f"Path does not exist or is not a directory: {movie_path}")
continue
# Check for IMDb ID
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id:
results["skipped"] += 1
_log("WARNING", f"Skipping {movie_path.name}: No IMDb ID found")
continue
# Process the movie
self.process_movie(movie_path)
results["processed"] += 1
except Exception as e:
error_msg = f"Error processing {movie_path}: {str(e)}"
results["errors"].append(error_msg)
_log("ERROR", error_msg)
continue
_log("INFO", f"Batch processing complete: {results['processed']} processed, "
f"{results['skipped']} skipped, {len(results['errors'])} errors")
return results
+416 -24
View File
@@ -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"
+206 -24
View File
@@ -3,45 +3,227 @@
Webhook batch processing logic for NFOGuard
"""
import asyncio
import threading
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Set, List, Any, Optional
from core.logging import _log
from core.nfo_manager import NFOManager
from config.settings import config
class WebhookBatcher:
"""Batches webhook processing to reduce duplicate work"""
"""Batches webhook events to avoid processing storms"""
def __init__(self, nfo_manager: NFOManager):
self.nfo_manager = nfo_manager
self.pending: Dict[str, Dict] = {}
self.batch_timeout = 30
self.processing_lock = asyncio.Lock()
self.timers: Dict[str, threading.Timer] = {}
self.processing: Set[str] = set()
self.lock = threading.Lock()
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent)
self.nfo_manager = nfo_manager
# NOTE: This is a placeholder extraction - the full 139-line class implementation
# needs to be copied from the original nfoguard.py file (lines 1562-1700)
#
# Key methods that need to be extracted include:
# - queue_sonarr_webhook()
# - queue_radarr_webhook()
# - _start_batch_timer()
# - _process_batched_webhooks()
# - Various helper methods for webhook processing
#
# This placeholder allows the import structure to work while we continue
# the refactoring process.
def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
"""Add webhook to batch queue"""
with self.lock:
if key in self.timers:
self.timers[key].cancel()
webhook_data['media_type'] = media_type
self.pending[key] = webhook_data
_log("INFO", f"Batched {media_type} webhook for {key}")
_log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s")
timer = threading.Timer(config.batch_delay, self._process_item, args=[key])
self.timers[key] = timer
timer.start()
def _process_item(self, key: str):
"""Process a batched item"""
with self.lock:
if key in self.processing or key not in self.pending:
return
self.processing.add(key)
webhook_data = self.pending.pop(key)
self.timers.pop(key, None)
try:
self.executor.submit(self._process_sync, key, webhook_data)
except Exception as e:
_log("ERROR", f"Error submitting processing for {key}: {e}")
with self.lock:
self.processing.discard(key)
def _process_sync(self, key: str, webhook_data: Dict):
"""Synchronous processing of webhook data with validation"""
try:
media_type = webhook_data.get('media_type')
path_str = webhook_data.get('path')
_log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}")
if not path_str:
_log("ERROR", f"No path found in webhook data for key: {key}")
return
path = Path(path_str)
if not path.exists():
_log("WARNING", f"Path does not exist: {path}")
return
# Import processors here to avoid circular imports
from processors.tv_processor import TVProcessor
from processors.movie_processor import MovieProcessor
from core.database import NFOGuardDatabase
from core.path_mapper import PathMapper
# Initialize components
db = NFOGuardDatabase(config.db_path)
path_mapper = PathMapper(config)
if media_type == 'tv':
processor = TVProcessor(db, self.nfo_manager, path_mapper)
# Check if this is a series or episode-specific processing
episodes_data = webhook_data.get('episodes', [])
if episodes_data:
processor.process_webhook_episodes(path, episodes_data)
else:
processor.process_series(path)
elif media_type == 'movie':
processor = MovieProcessor(db, self.nfo_manager, path_mapper)
processor.process_movie(path, webhook_mode=True)
else:
_log("ERROR", f"Unknown media type: {media_type}")
return
_log("INFO", f"Completed processing {media_type} webhook for: {path.name}")
except Exception as e:
_log("ERROR", f"Error processing batch item {key}: {e}")
finally:
with self.lock:
self.processing.discard(key)
async def queue_sonarr_webhook(self, webhook_data: Dict[str, Any]) -> None:
"""Queue Sonarr webhook for batch processing - PLACEHOLDER"""
_log("INFO", f"Queuing Sonarr webhook: {webhook_data.get('eventType', 'Unknown')}")
# Full implementation needs to be copied from original file
pass
"""Queue Sonarr webhook for batch processing"""
try:
event_type = webhook_data.get('eventType')
series = webhook_data.get('series', {})
episodes = webhook_data.get('episodes', [])
if not series:
_log("WARNING", "No series data in Sonarr webhook")
return
series_title = series.get('title', 'Unknown')
imdb_id = series.get('imdbId')
series_path = series.get('path')
_log("INFO", f"Queuing Sonarr {event_type} webhook: {series_title}")
# Create batch key (use IMDb ID if available, otherwise title)
batch_key = imdb_id if imdb_id else f"series_{series_title.replace(' ', '_')}"
# Find series path
from processors.tv_processor import TVProcessor
from core.database import NFOGuardDatabase
from core.path_mapper import PathMapper
db = NFOGuardDatabase(config.db_path)
path_mapper = PathMapper(config)
tv_processor = TVProcessor(db, self.nfo_manager, path_mapper)
found_path = tv_processor.find_series_path(series_title, imdb_id, series_path)
if not found_path:
_log("WARNING", f"Could not find series path for: {series_title}")
return
# Prepare webhook data for processing
batch_data = {
'path': str(found_path),
'series': series,
'episodes': episodes,
'event_type': event_type
}
# Add to batch
self.add_webhook(batch_key, batch_data, 'tv')
except Exception as e:
_log("ERROR", f"Error queuing Sonarr webhook: {e}")
async def queue_radarr_webhook(self, webhook_data: Dict[str, Any]) -> None:
"""Queue Radarr webhook for batch processing - PLACEHOLDER"""
_log("INFO", f"Queuing Radarr webhook: {webhook_data.get('eventType', 'Unknown')}")
# Full implementation needs to be copied from original file
pass
"""Queue Radarr webhook for batch processing"""
try:
event_type = webhook_data.get('eventType')
movie = webhook_data.get('movie', {})
if not movie:
_log("WARNING", "No movie data in Radarr webhook")
return
movie_title = movie.get('title', 'Unknown')
imdb_id = movie.get('imdbId')
movie_path = movie.get('folderPath')
_log("INFO", f"Queuing Radarr {event_type} webhook: {movie_title}")
# Create batch key (use IMDb ID if available, otherwise title)
batch_key = imdb_id if imdb_id else f"movie_{movie_title.replace(' ', '_')}"
# Find movie path
from processors.movie_processor import MovieProcessor
from core.database import NFOGuardDatabase
from core.path_mapper import PathMapper
db = NFOGuardDatabase(config.db_path)
path_mapper = PathMapper(config)
movie_processor = MovieProcessor(db, self.nfo_manager, path_mapper)
found_path = movie_processor.find_movie_path(movie_title, imdb_id, movie_path)
if not found_path:
_log("WARNING", f"Could not find movie path for: {movie_title}")
return
# Prepare webhook data for processing
batch_data = {
'path': str(found_path),
'movie': movie,
'event_type': event_type
}
# Add to batch
self.add_webhook(batch_key, batch_data, 'movie')
except Exception as e:
_log("ERROR", f"Error queuing Radarr webhook: {e}")
def get_pending_count(self) -> int:
"""Get count of pending webhooks"""
with self.lock:
return len(self.pending)
def get_processing_count(self) -> int:
"""Get count of currently processing webhooks"""
with self.lock:
return len(self.processing)
def shutdown(self):
"""Shutdown the webhook batcher"""
_log("INFO", "Shutting down WebhookBatcher...")
# Cancel all pending timers
with self.lock:
for timer in self.timers.values():
timer.cancel()
self.timers.clear()
# Shutdown executor
self.executor.shutdown(wait=True)
_log("INFO", "WebhookBatcher shutdown complete")