refactor
This commit is contained in:
+858
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFOGuard - Consolidated webhook server for TV and Movie import date management
|
||||
Replaces webhook_server.py and media_date_cache.py with modular architecture
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import glob
|
||||
import re
|
||||
import logging
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any, List, Set, Tuple
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import uvicorn
|
||||
|
||||
# Import our new modular components
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from clients.external_clients import ExternalClientManager
|
||||
|
||||
# ---------------------------
|
||||
# Configuration & Logging
|
||||
# ---------------------------
|
||||
|
||||
def _setup_file_logging():
|
||||
"""Setup file logging for NFOGuard"""
|
||||
log_dir = Path("/app/data/logs")
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("NFOGuard")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||
)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s',
|
||||
datefmt='%Y-%m-%dT%H:%M:%S+00:00'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
return logger
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Enhanced logging that writes to both console and file"""
|
||||
print(f"[{datetime.now(timezone.utc).isoformat(timespec='seconds')}] {level}: {msg}")
|
||||
|
||||
try:
|
||||
file_logger = _setup_file_logging()
|
||||
getattr(file_logger, level.lower(), file_logger.info)(msg)
|
||||
except Exception as e:
|
||||
print(f"File logging error: {e}")
|
||||
|
||||
# Initialize logging
|
||||
_setup_file_logging()
|
||||
|
||||
def _bool_env(name: str, default: bool) -> bool:
|
||||
v = os.environ.get(name)
|
||||
if v is None:
|
||||
return default
|
||||
return v.lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
# ---------------------------
|
||||
# Configuration
|
||||
# ---------------------------
|
||||
|
||||
class NFOGuardConfig:
|
||||
def __init__(self):
|
||||
# Paths
|
||||
self.tv_paths = [Path(p.strip()) for p in os.environ.get("TV_PATHS", "/media/tv,/media/tv6").split(",") if p.strip()]
|
||||
self.movie_paths = [Path(p.strip()) for p in os.environ.get("MOVIE_PATHS", "/media/movies,/media/movies6").split(",") if p.strip()]
|
||||
|
||||
# Core settings
|
||||
self.manage_nfo = _bool_env("MANAGE_NFO", True)
|
||||
self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True)
|
||||
self.lock_metadata = _bool_env("LOCK_METADATA", True)
|
||||
self.debug = _bool_env("DEBUG", False)
|
||||
self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
|
||||
|
||||
# Batching
|
||||
self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0"))
|
||||
self.max_concurrent = int(os.environ.get("MAX_CONCURRENT_SERIES", "3"))
|
||||
|
||||
# Database
|
||||
self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
|
||||
|
||||
# Movie processing
|
||||
self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower()
|
||||
self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower()
|
||||
self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower()
|
||||
|
||||
config = NFOGuardConfig()
|
||||
|
||||
# ---------------------------
|
||||
# Models
|
||||
# ---------------------------
|
||||
|
||||
class SonarrWebhook(BaseModel):
|
||||
eventType: str
|
||||
series: Optional[Dict[str, Any]] = None
|
||||
episodes: Optional[list] = []
|
||||
episodeFile: Optional[Dict[str, Any]] = None
|
||||
isUpgrade: Optional[bool] = False
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
class RadarrWebhook(BaseModel):
|
||||
eventType: str
|
||||
movie: Optional[Dict[str, Any]] = None
|
||||
movieFile: Optional[Dict[str, Any]] = None
|
||||
isUpgrade: Optional[bool] = False
|
||||
deletedFiles: Optional[list] = []
|
||||
remoteMovie: Optional[Dict[str, Any]] = None
|
||||
renamedMovieFiles: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
uptime: str
|
||||
database_status: str
|
||||
|
||||
# ---------------------------
|
||||
# Core Processing
|
||||
# ---------------------------
|
||||
|
||||
class TVProcessor:
|
||||
"""Handles TV series processing"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager
|
||||
self.path_mapper = path_mapper
|
||||
self.sonarr = SonarrClient(
|
||||
os.environ.get("SONARR_URL", ""),
|
||||
os.environ.get("SONARR_API_KEY", "")
|
||||
)
|
||||
self.external_clients = ExternalClientManager()
|
||||
|
||||
def find_series_path(self, series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]:
|
||||
"""Find series directory path"""
|
||||
# Try webhook path first
|
||||
if sonarr_path:
|
||||
container_path = self.path_mapper.sonarr_path_to_container_path(sonarr_path)
|
||||
path_obj = Path(container_path)
|
||||
if path_obj.exists():
|
||||
return path_obj
|
||||
|
||||
# Search by IMDb ID or title
|
||||
for media_path in config.tv_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 series_title:
|
||||
title_clean = series_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_series(self, series_path: Path) -> None:
|
||||
"""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:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
series_path / f"Season {season:02d}",
|
||||
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 / f"Season {season:02d}"
|
||||
self.nfo_manager.create_season_nfo(season_dir, season)
|
||||
seasons_processed.add(season)
|
||||
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_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("season ")):
|
||||
continue
|
||||
|
||||
try:
|
||||
season_num = int(season_dir.name.split()[-1])
|
||||
except Exception:
|
||||
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"])
|
||||
|
||||
# Find missing episodes
|
||||
cached_keys = set(episode_dates.keys())
|
||||
missing_keys = set(disk_episodes.keys()) - cached_keys
|
||||
|
||||
if not missing_keys:
|
||||
_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
|
||||
|
||||
|
||||
class MovieProcessor:
|
||||
"""Handles movie processing"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager
|
||||
self.path_mapper = path_mapper
|
||||
self.radarr = RadarrClient(
|
||||
os.environ.get("RADARR_URL", ""),
|
||||
os.environ.get("RADARR_API_KEY", "")
|
||||
)
|
||||
self.external_clients = ExternalClientManager()
|
||||
|
||||
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) -> None:
|
||||
"""Process a movie directory"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(movie_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in movie path: {movie_path}")
|
||||
return
|
||||
|
||||
_log("INFO", f"Processing movie: {movie_path.name}")
|
||||
|
||||
# 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
|
||||
|
||||
# Get existing dates
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
|
||||
# Determine if we should query APIs
|
||||
should_query = (
|
||||
config.movie_poll_mode == "always" or
|
||||
(config.movie_poll_mode == "if_missing" and not existing) or
|
||||
(config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime")
|
||||
)
|
||||
|
||||
# Decide dates
|
||||
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
|
||||
|
||||
# Create NFO
|
||||
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 and dateadded != "MANUAL_REVIEW_NEEDED":
|
||||
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})")
|
||||
|
||||
def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
|
||||
"""Decide movie dates based on configuration and available data"""
|
||||
if not should_query and existing:
|
||||
return existing["dateadded"], existing["source"], existing.get("released")
|
||||
|
||||
# Query Radarr for movie info
|
||||
radarr_movie = None
|
||||
if should_query and self.radarr.api_key:
|
||||
radarr_movie = self.radarr.movie_by_imdb(imdb_id)
|
||||
|
||||
released = None
|
||||
if radarr_movie:
|
||||
released = self._parse_date_to_iso(radarr_movie.get("inCinemas"))
|
||||
|
||||
# Try import history first if configured
|
||||
if config.movie_priority == "import_then_digital":
|
||||
if radarr_movie:
|
||||
movie_id = radarr_movie.get("id")
|
||||
if movie_id:
|
||||
import_date, import_source = self.radarr.get_movie_import_date(movie_id)
|
||||
if import_date:
|
||||
return import_date, import_source, released
|
||||
|
||||
# Fall back to digital release
|
||||
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
||||
if digital_date:
|
||||
return digital_date, digital_source, released
|
||||
|
||||
else: # digital_then_import
|
||||
# Try digital release first
|
||||
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
||||
if digital_date:
|
||||
return digital_date, digital_source, released
|
||||
|
||||
# Fall back to import history
|
||||
if radarr_movie:
|
||||
movie_id = radarr_movie.get("id")
|
||||
if movie_id:
|
||||
import_date, import_source = self.radarr.get_movie_import_date(movie_id)
|
||||
if import_date:
|
||||
return import_date, import_source, released
|
||||
|
||||
# Last resort: file mtime
|
||||
return self._get_file_mtime_date(movie_path)
|
||||
|
||||
def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]:
|
||||
"""Get digital release date from external sources"""
|
||||
digital_release = self.external_clients.get_earliest_digital_release(imdb_id)
|
||||
if digital_release:
|
||||
return digital_release[0], digital_release[1]
|
||||
return None, "digital:none"
|
||||
|
||||
def _get_file_mtime_date(self, movie_path: Path) -> Tuple[str, str, Optional[str]]:
|
||||
"""Get date from file modification time as last resort"""
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||
newest_mtime = None
|
||||
|
||||
for file_path in movie_path.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
||||
try:
|
||||
mtime = file_path.stat().st_mtime
|
||||
if newest_mtime is None or mtime > newest_mtime:
|
||||
newest_mtime = mtime
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if newest_mtime:
|
||||
try:
|
||||
iso_date = datetime.fromtimestamp(newest_mtime, tz=timezone.utc).isoformat(timespec="seconds")
|
||||
return iso_date, "file:mtime", None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "MANUAL_REVIEW_NEEDED", "manual_review_required", None
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Batching System
|
||||
# ---------------------------
|
||||
|
||||
class WebhookBatcher:
|
||||
"""Batches webhook events to avoid processing storms"""
|
||||
|
||||
def __init__(self):
|
||||
self.pending: Dict[str, Dict] = {}
|
||||
self.timers: Dict[str, threading.Timer] = {}
|
||||
self.processing: Set[str] = set()
|
||||
self.lock = threading.Lock()
|
||||
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent)
|
||||
|
||||
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}")
|
||||
|
||||
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"""
|
||||
try:
|
||||
media_type = webhook_data.get('media_type')
|
||||
path_str = webhook_data.get('path')
|
||||
|
||||
if not path_str:
|
||||
_log("ERROR", f"No path found for {media_type} {key}")
|
||||
return
|
||||
|
||||
path_obj = Path(path_str)
|
||||
if not path_obj.exists():
|
||||
_log("ERROR", f"Path does not exist: {path_obj}")
|
||||
return
|
||||
|
||||
# Process based on media type
|
||||
if media_type == 'tv':
|
||||
tv_processor.process_series(path_obj)
|
||||
elif media_type == 'movie':
|
||||
movie_processor.process_movie(path_obj)
|
||||
else:
|
||||
_log("ERROR", f"Unknown media type: {media_type}")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing {media_type} {key}: {e}")
|
||||
finally:
|
||||
with self.lock:
|
||||
self.processing.discard(key)
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Get batch queue status"""
|
||||
with self.lock:
|
||||
return {
|
||||
"pending_items": list(self.pending.keys()),
|
||||
"processing_items": list(self.processing),
|
||||
"pending_count": len(self.pending),
|
||||
"processing_count": len(self.processing)
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# FastAPI Application
|
||||
# ---------------------------
|
||||
|
||||
# Get version
|
||||
try:
|
||||
version = (Path(__file__).parent / "VERSION").read_text().strip()
|
||||
except:
|
||||
version = "0.1.0"
|
||||
|
||||
app = FastAPI(
|
||||
title="NFOGuard",
|
||||
description="Webhook server for preserving media import dates",
|
||||
version=version
|
||||
)
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Initialize components
|
||||
db = NFOGuardDatabase(config.db_path)
|
||||
nfo_manager = NFOManager(config.manager_brand)
|
||||
path_mapper = PathMapper()
|
||||
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
|
||||
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
|
||||
batcher = WebhookBatcher()
|
||||
|
||||
# ---------------------------
|
||||
# Webhook Handlers
|
||||
# ---------------------------
|
||||
|
||||
async def _read_payload(request: Request) -> dict:
|
||||
"""Read webhook payload from request"""
|
||||
content_type = (request.headers.get("content-type") or "").lower()
|
||||
try:
|
||||
if "application/json" in content_type:
|
||||
return await request.json()
|
||||
form = await request.form()
|
||||
if "payload" in form:
|
||||
return json.loads(form["payload"])
|
||||
return dict(form)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to read webhook payload: {e}")
|
||||
return {}
|
||||
|
||||
@app.post("/webhook/sonarr")
|
||||
async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle Sonarr webhooks"""
|
||||
try:
|
||||
payload = await _read_payload(request)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty Sonarr payload")
|
||||
|
||||
webhook = SonarrWebhook(**payload)
|
||||
_log("INFO", f"Received Sonarr webhook: {webhook.eventType}")
|
||||
|
||||
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
|
||||
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
||||
|
||||
if not webhook.series:
|
||||
return {"status": "ignored", "reason": "No series data"}
|
||||
|
||||
series_info = webhook.series
|
||||
series_title = series_info.get("title", "")
|
||||
imdb_id = series_info.get("imdbId", "").replace("tt", "").strip()
|
||||
if imdb_id:
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
sonarr_path = series_info.get("path", "")
|
||||
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID for series: {series_title}")
|
||||
return {"status": "error", "reason": "No IMDb ID"}
|
||||
|
||||
# Find series path
|
||||
series_path = tv_processor.find_series_path(series_title, imdb_id, sonarr_path)
|
||||
if not series_path:
|
||||
_log("ERROR", f"Could not find series directory: {series_title} ({imdb_id})")
|
||||
return {"status": "error", "reason": "Series directory not found"}
|
||||
|
||||
# Add to batch queue
|
||||
webhook_dict = {
|
||||
'path': str(series_path),
|
||||
'series_info': series_info,
|
||||
'event_type': webhook.eventType
|
||||
}
|
||||
batcher.add_webhook(imdb_id, webhook_dict, 'tv')
|
||||
|
||||
return {"status": "accepted", "message": "Sonarr webhook queued"}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Sonarr webhook error: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
|
||||
|
||||
@app.post("/webhook/radarr")
|
||||
async def radarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle Radarr webhooks"""
|
||||
try:
|
||||
payload = await _read_payload(request)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty Radarr payload")
|
||||
|
||||
webhook = RadarrWebhook(**payload)
|
||||
_log("INFO", f"Received Radarr webhook: {webhook.eventType}")
|
||||
|
||||
if webhook.eventType not in ["Download", "Upgrade", "Rename", "Test"]:
|
||||
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
||||
|
||||
if webhook.eventType == "Test":
|
||||
return {"status": "ok", "message": "Test received"}
|
||||
|
||||
if not webhook.movie:
|
||||
return {"status": "ignored", "reason": "No movie data"}
|
||||
|
||||
movie_info = webhook.movie
|
||||
movie_title = movie_info.get("title", "")
|
||||
imdb_id = (movie_info.get("imdbId") or "").strip()
|
||||
if imdb_id:
|
||||
imdb_id = f"tt{imdb_id.replace('tt','')}"
|
||||
radarr_path = movie_info.get("folderPath") or movie_info.get("path", "")
|
||||
|
||||
# Find movie path
|
||||
movie_path = movie_processor.find_movie_path(movie_title, imdb_id, radarr_path)
|
||||
if not movie_path:
|
||||
_log("ERROR", f"Could not find movie directory: {movie_title} ({imdb_id})")
|
||||
return {"status": "error", "reason": "Movie directory not found"}
|
||||
|
||||
# Extract IMDb ID from path if not in webhook
|
||||
if not imdb_id:
|
||||
imdb_id = nfo_manager.parse_imdb_from_path(movie_path)
|
||||
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID available for movie: {movie_title}")
|
||||
return {"status": "error", "reason": "No IMDb ID"}
|
||||
|
||||
# Add to batch queue
|
||||
webhook_dict = {
|
||||
'path': str(movie_path),
|
||||
'movie_info': movie_info,
|
||||
'event_type': webhook.eventType
|
||||
}
|
||||
batcher.add_webhook(imdb_id, webhook_dict, 'movie')
|
||||
|
||||
return {"status": "accepted", "message": "Radarr webhook queued"}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Radarr webhook error: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
|
||||
|
||||
# ---------------------------
|
||||
# API Endpoints
|
||||
# ---------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> HealthResponse:
|
||||
"""Health check endpoint"""
|
||||
uptime = datetime.now(timezone.utc) - start_time
|
||||
try:
|
||||
with db.get_connection() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"error: {e}"
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy" if db_status == "healthy" else "degraded",
|
||||
version=version,
|
||||
uptime=str(uptime),
|
||||
database_status=db_status
|
||||
)
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""Get database statistics"""
|
||||
try:
|
||||
return db.get_stats()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/batch/status")
|
||||
async def batch_status():
|
||||
"""Get batch queue status"""
|
||||
return batcher.get_status()
|
||||
|
||||
@app.post("/manual/scan")
|
||||
async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"):
|
||||
"""Manual scan endpoint"""
|
||||
if scan_type not in ["both", "tv", "movies"]:
|
||||
raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'")
|
||||
|
||||
async def run_scan():
|
||||
paths_to_scan = []
|
||||
if path:
|
||||
paths_to_scan = [Path(path)]
|
||||
else:
|
||||
if scan_type in ["both", "tv"]:
|
||||
paths_to_scan.extend(config.tv_paths)
|
||||
if scan_type in ["both", "movies"]:
|
||||
paths_to_scan.extend(config.movie_paths)
|
||||
|
||||
for scan_path in paths_to_scan:
|
||||
if not scan_path.exists():
|
||||
continue
|
||||
|
||||
if scan_type in ["both", "tv"] and scan_path in config.tv_paths:
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
|
||||
try:
|
||||
tv_processor.process_series(item)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing TV series {item}: {e}")
|
||||
|
||||
if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.parse_imdb_from_path(item):
|
||||
try:
|
||||
movie_processor.process_movie(item)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed processing movie {item}: {e}")
|
||||
|
||||
background_tasks.add_task(run_scan)
|
||||
return {"status": "started", "message": f"Manual {scan_type} scan started"}
|
||||
|
||||
# ---------------------------
|
||||
# Main
|
||||
# ---------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("INFO", "Starting NFOGuard")
|
||||
_log("INFO", f"Version: {version}")
|
||||
_log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}")
|
||||
_log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}")
|
||||
_log("INFO", f"Database: {config.db_path}")
|
||||
_log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}")
|
||||
_log("INFO", f"Movie priority: {config.movie_priority}")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=int(os.environ.get("PORT", "8080")),
|
||||
reload=False
|
||||
)
|
||||
Reference in New Issue
Block a user