This commit is contained in:
+41
-9
@@ -72,9 +72,9 @@ def register_routes(
|
|||||||
_log("WARNING", "No series data in Sonarr webhook")
|
_log("WARNING", "No series data in Sonarr webhook")
|
||||||
return {"status": "error", "message": "No series data"}
|
return {"status": "error", "message": "No series data"}
|
||||||
|
|
||||||
# Process the webhook (implementation would continue from original file)
|
# Process the webhook using the batcher
|
||||||
# This is a placeholder - full implementation needs to be copied from nfoguard.py
|
await batcher.queue_sonarr_webhook(payload)
|
||||||
return {"status": "queued", "message": "Sonarr webhook queued for processing"}
|
return {"status": "queued", "message": f"Sonarr {webhook.eventType} webhook queued for processing"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("ERROR", f"Sonarr webhook error: {e}")
|
_log("ERROR", f"Sonarr webhook error: {e}")
|
||||||
@@ -98,9 +98,9 @@ def register_routes(
|
|||||||
_log("WARNING", "No movie data in Radarr webhook")
|
_log("WARNING", "No movie data in Radarr webhook")
|
||||||
return {"status": "error", "message": "No movie data"}
|
return {"status": "error", "message": "No movie data"}
|
||||||
|
|
||||||
# Process the webhook (implementation would continue from original file)
|
# Process the webhook using the batcher
|
||||||
# This is a placeholder - full implementation needs to be copied from nfoguard.py
|
await batcher.queue_radarr_webhook(payload)
|
||||||
return {"status": "queued", "message": "Radarr webhook queued for processing"}
|
return {"status": "queued", "message": f"Radarr {webhook.eventType} webhook queued for processing"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("ERROR", f"Radarr webhook error: {e}")
|
_log("ERROR", f"Radarr webhook error: {e}")
|
||||||
@@ -158,16 +158,48 @@ def register_routes(
|
|||||||
async def batch_status():
|
async def batch_status():
|
||||||
"""Get current batch processing status"""
|
"""Get current batch processing status"""
|
||||||
return {
|
return {
|
||||||
"message": "Batch status endpoint - implementation needed",
|
"pending_items": batcher.get_pending_count(),
|
||||||
"pending_items": len(getattr(batcher, 'pending', {}))
|
"processing_items": batcher.get_processing_count(),
|
||||||
|
"total_queued": batcher.get_pending_count() + batcher.get_processing_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/manual/scan")
|
@app.post("/manual/scan")
|
||||||
async def manual_scan(background_tasks: BackgroundTasks):
|
async def manual_scan(background_tasks: BackgroundTasks):
|
||||||
"""Trigger manual library scan"""
|
"""Trigger manual library scan"""
|
||||||
try:
|
try:
|
||||||
# Add the actual manual scan logic here
|
|
||||||
_log("INFO", "Manual scan triggered via API")
|
_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"}
|
return {"status": "started", "message": "Manual scan initiated"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log("ERROR", f"Manual scan failed: {e}")
|
_log("ERROR", f"Manual scan failed: {e}")
|
||||||
|
|||||||
+384
-28
@@ -3,6 +3,7 @@
|
|||||||
Movie processing logic for NFOGuard
|
Movie processing logic for NFOGuard
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import glob
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -30,33 +31,388 @@ class MovieProcessor:
|
|||||||
)
|
)
|
||||||
self.external_clients = ExternalClientManager()
|
self.external_clients = ExternalClientManager()
|
||||||
|
|
||||||
# NOTE: This is a placeholder extraction - the full 500-line class implementation
|
def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
|
||||||
# needs to be copied from the original nfoguard.py file (lines 1062-1561)
|
"""Find movie directory path"""
|
||||||
#
|
# Try webhook path first
|
||||||
# Key methods that need to be extracted include:
|
if radarr_path:
|
||||||
# - process_movie()
|
container_path = self.path_mapper.radarr_path_to_container_path(radarr_path)
|
||||||
# - validate_batch_movies()
|
path_obj = Path(container_path)
|
||||||
# - get_movie_dates()
|
if path_obj.exists():
|
||||||
# - _should_query_external_apis()
|
return path_obj
|
||||||
# - Various helper methods for date handling and processing
|
|
||||||
#
|
# Search by IMDb ID or title
|
||||||
# This placeholder allows the import structure to work while we continue
|
for media_path in config.movie_paths:
|
||||||
# the refactoring process.
|
if not media_path.exists():
|
||||||
|
continue
|
||||||
def process_movie(self, movie_path: Path, imdb_id: str = None) -> bool:
|
|
||||||
"""Process a single movie - PLACEHOLDER"""
|
# Search by IMDb ID
|
||||||
_log("INFO", f"Processing movie: {movie_path}")
|
if imdb_id:
|
||||||
# Full implementation needs to be copied from original file
|
pattern = str(media_path / f"*[imdb-{imdb_id}]*")
|
||||||
return True
|
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]:
|
def validate_batch_movies(self, movie_paths: List[Path]) -> Dict[str, Any]:
|
||||||
"""Validate batch of movies - PLACEHOLDER"""
|
"""Validate and process a batch of movies"""
|
||||||
_log("INFO", f"Validating {len(movie_paths)} movies")
|
results = {
|
||||||
# Full implementation needs to be copied from original file
|
"processed": 0,
|
||||||
return {"processed": 0, "errors": []}
|
"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}")
|
_log("INFO", f"Starting batch processing of {len(movie_paths)} movies")
|
||||||
# Full implementation needs to be copied from original file
|
|
||||||
return {"source": "placeholder", "dateadded": None}
|
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
@@ -4,6 +4,7 @@ TV Show processing logic for NFOGuard
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
@@ -63,30 +64,421 @@ class TVProcessor:
|
|||||||
|
|
||||||
return None
|
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:
|
def process_series(self, series_path: Path) -> None:
|
||||||
"""Process a TV series directory - PLACEHOLDER"""
|
"""Process a TV series directory"""
|
||||||
_log("INFO", f"Processing TV series: {series_path}")
|
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||||
# Full implementation needs to be copied from original file
|
if not imdb_id:
|
||||||
pass
|
_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:
|
def process_webhook_episodes(self, series_path: Path, episodes_data: List[Dict]) -> None:
|
||||||
"""Process specific episodes from webhook data - PLACEHOLDER"""
|
"""Process specific episodes from webhook data"""
|
||||||
_log("INFO", f"Processing {len(episodes_data)} episodes for series: {series_path}")
|
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||||
# Full implementation needs to be copied from original file
|
if not imdb_id:
|
||||||
pass
|
_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
@@ -3,45 +3,227 @@
|
|||||||
Webhook batch processing logic for NFOGuard
|
Webhook batch processing logic for NFOGuard
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Set, List, Any, Optional
|
from typing import Dict, Set, List, Any, Optional
|
||||||
|
|
||||||
from core.logging import _log
|
from core.logging import _log
|
||||||
from core.nfo_manager import NFOManager
|
from core.nfo_manager import NFOManager
|
||||||
|
from config.settings import config
|
||||||
|
|
||||||
|
|
||||||
class WebhookBatcher:
|
class WebhookBatcher:
|
||||||
"""Batches webhook processing to reduce duplicate work"""
|
"""Batches webhook events to avoid processing storms"""
|
||||||
|
|
||||||
def __init__(self, nfo_manager: NFOManager):
|
def __init__(self, nfo_manager: NFOManager):
|
||||||
self.nfo_manager = nfo_manager
|
|
||||||
self.pending: Dict[str, Dict] = {}
|
self.pending: Dict[str, Dict] = {}
|
||||||
self.batch_timeout = 30
|
self.timers: Dict[str, threading.Timer] = {}
|
||||||
self.processing_lock = asyncio.Lock()
|
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
|
def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
|
||||||
# needs to be copied from the original nfoguard.py file (lines 1562-1700)
|
"""Add webhook to batch queue"""
|
||||||
#
|
with self.lock:
|
||||||
# Key methods that need to be extracted include:
|
if key in self.timers:
|
||||||
# - queue_sonarr_webhook()
|
self.timers[key].cancel()
|
||||||
# - queue_radarr_webhook()
|
|
||||||
# - _start_batch_timer()
|
webhook_data['media_type'] = media_type
|
||||||
# - _process_batched_webhooks()
|
self.pending[key] = webhook_data
|
||||||
# - Various helper methods for webhook processing
|
_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")
|
||||||
# This placeholder allows the import structure to work while we continue
|
|
||||||
# the refactoring process.
|
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:
|
async def queue_sonarr_webhook(self, webhook_data: Dict[str, Any]) -> None:
|
||||||
"""Queue Sonarr webhook for batch processing - PLACEHOLDER"""
|
"""Queue Sonarr webhook for batch processing"""
|
||||||
_log("INFO", f"Queuing Sonarr webhook: {webhook_data.get('eventType', 'Unknown')}")
|
try:
|
||||||
# Full implementation needs to be copied from original file
|
event_type = webhook_data.get('eventType')
|
||||||
pass
|
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:
|
async def queue_radarr_webhook(self, webhook_data: Dict[str, Any]) -> None:
|
||||||
"""Queue Radarr webhook for batch processing - PLACEHOLDER"""
|
"""Queue Radarr webhook for batch processing"""
|
||||||
_log("INFO", f"Queuing Radarr webhook: {webhook_data.get('eventType', 'Unknown')}")
|
try:
|
||||||
# Full implementation needs to be copied from original file
|
event_type = webhook_data.get('eventType')
|
||||||
pass
|
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")
|
||||||
Reference in New Issue
Block a user