diff --git a/api/routes.py b/api/routes.py
index e003661..de6c0ed 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -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}")
diff --git a/processors/movie_processor.py b/processors/movie_processor.py
index 3766d1a..9197fd7 100644
--- a/processors/movie_processor.py
+++ b/processors/movie_processor.py
@@ -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 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
+ 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
+
+ # 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'([^<]+)', content)
+ if dateadded_match:
+ dateadded = dateadded_match.group(1).strip()
+
+ # Extract premiered (release date)
+ premiered_match = re.search(r'([^<]+)', 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": []}
-
- 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}
\ No newline at end of file
+ """Validate and process a batch of movies"""
+ results = {
+ "processed": 0,
+ "errors": [],
+ "skipped": 0
+ }
+
+ _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
\ No newline at end of file
diff --git a/processors/tv_processor.py b/processors/tv_processor.py
index e331f18..3ffed03 100644
--- a/processors/tv_processor.py
+++ b/processors/tv_processor.py
@@ -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
\ No newline at end of file
+ """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"
\ No newline at end of file
diff --git a/webhooks/webhook_batcher.py b/webhooks/webhook_batcher.py
index 6514ff5..a2c52b5 100644
--- a/webhooks/webhook_batcher.py
+++ b/webhooks/webhook_batcher.py
@@ -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
\ No newline at end of file
+ """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")
\ No newline at end of file