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