#!/usr/bin/env python3 # webhook_server.py - FastAPI webhook server for media date cache import os import json import asyncio import glob import re from pathlib import Path from datetime import datetime, timezone from fastapi import FastAPI, HTTPException, BackgroundTasks, Request from pydantic import BaseModel from typing import Optional, Dict, Any, List, Set import sqlite3 import uvicorn import threading import time from concurrent.futures import ThreadPoolExecutor # Import our existing logic from media_date_cache import ( db_connect, apply_series, parse_imdb_from_path, _log, _iso_now, SonarrClient ) # Pydantic models for webhook payloads class SonarrWebhook(BaseModel): eventType: str series: Optional[Dict[str, Any]] = None episodes: Optional[list] = [] episodeFile: Optional[Dict[str, Any]] = None isUpgrade: Optional[bool] = False movie: Optional[Dict[str, Any]] = None remoteMovie: Optional[Dict[str, Any]] = None deletedFiles: Optional[list] = [] class Config: extra = "allow" # Allow extra fields not defined in the model 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]]] = [] # present on Rename class Config: extra = "allow" class HealthResponse(BaseModel): status: str version: str uptime: str database_status: str app = FastAPI( title="Media Date Cache Webhook Server", description="Webhook server for managing TV show and movie import dates and NFO files", version="1.0.0" ) # Global state start_time = datetime.now(timezone.utc) db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db")) class WebhookConfig: def __init__(self): # TV paths tv_paths_str = os.environ.get("TV_PATHS", os.environ.get("MEDIA_PATH", "/media/tv,/media/tv6")) self.tv_paths = [Path(p.strip()) for p in tv_paths_str.split(",")] # Movie paths movie_paths_str = os.environ.get("MOVIE_PATHS", "/media/movies,/media/movies6") self.movie_paths = [Path(p.strip()) for p in movie_paths_str.split(",")] self.manage_nfo = os.environ.get("MANAGE_NFO", "true").lower() == "true" self.fix_dir_mtimes = os.environ.get("FIX_DIR_MTIMES", "true").lower() == "true" self.lock_metadata = os.environ.get("LOCK_METADATA", "true").lower() == "true" self.debug = os.environ.get("DEBUG", "false").lower() == "true" # Batching configuration self.batch_delay = float(os.environ.get("BATCH_DELAY", "5.0")) # seconds self.max_concurrent_series = int(os.environ.get("MAX_CONCURRENT_SERIES", "3")) config = WebhookConfig() # Movie database schema additions MOVIE_SCHEMA = """ CREATE TABLE IF NOT EXISTS movies ( imdb_id TEXT PRIMARY KEY, movie_path TEXT NOT NULL, last_applied TEXT ); CREATE TABLE IF NOT EXISTS movie_dates ( imdb_id TEXT PRIMARY KEY, released TEXT, dateadded TEXT, source TEXT, has_video_file INTEGER DEFAULT 0 ); """ def ensure_movie_tables(conn: sqlite3.Connection): """Ensure movie tables exist in the database""" try: result = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='movies'").fetchone() if not result: _log("INFO", "Creating movie tables in database") conn.executescript(MOVIE_SCHEMA) conn.commit() except sqlite3.Error as e: _log("ERROR", f"Failed to create movie tables: {e}") # Movie processing functions def parse_imdb_from_movie_path(p: Path): """Extract IMDb ID from movie directory name""" # Look for patterns like: Movie Name (2023) [imdb-tt1234567] m = re.search(r'\[imdb-(tt\d+)\]', p.name, re.IGNORECASE) return m.group(1).lower() if m else None def is_movie_dir(p: Path) -> bool: """Check if path is a movie directory with IMDb ID""" return p.is_dir() and parse_imdb_from_movie_path(p) is not None def write_movie_nfo(movie_dir: Path, imdb_id: str, released: str, dateadded: str, source: str, lock_metadata: bool = True): """Write movie NFO file""" try: movie_dir.mkdir(parents=True, exist_ok=True) except Exception as e: _log("ERROR", f"Failed creating movie directory {movie_dir}: {e}") return nfo_file = movie_dir / f"{movie_dir.name}.nfo" xml = [ '', "", f" {imdb_id}", f" {imdb_id}", ] if released: xml.append(f" {released.split('T')[0]}") xml.append(f" {released.split('-')[0]}") if dateadded: xml.append(f" {dateadded}") if lock_metadata: xml.append(" true") if source: xml.append(f" ") xml.append(f" ") xml.append("\n") content = "\n".join(xml) try: nfo_file.write_text(content, encoding="utf-8") _log("DEBUG", f"Created movie NFO: {nfo_file}") except Exception as e: _log("ERROR", f"Failed writing {nfo_file}: {e}") def set_movie_file_mtime(movie_dir: Path, dateadded_iso: str): """Set movie file modification time to match dateadded""" if not dateadded_iso: return try: ts = datetime.fromisoformat(dateadded_iso.replace("Z", "+00:00")).timestamp() # Find video files video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") for f in movie_dir.iterdir(): if f.is_file() and f.suffix.lower() in video_exts: try: os.utime(f, (ts, ts), follow_symlinks=False) _log("DEBUG", f"Updated movie file mtime: {f} -> {dateadded_iso}") except Exception as e: _log("WARNING", f"Failed to update mtime on {f}: {e}") # Set directory mtime too try: os.utime(movie_dir, (ts, ts), follow_symlinks=False) _log("DEBUG", f"Updated movie dir mtime: {movie_dir} -> {dateadded_iso}") except Exception as e: _log("WARNING", f"Failed to update mtime on {movie_dir}: {e}") except Exception as e: _log("WARNING", f"Bad timestamp for movie mtimes: {dateadded_iso}: {e}") def apply_movie(movie_dir: Path, conn: sqlite3.Connection): """Apply date caching to a movie directory""" imdb_id = parse_imdb_from_movie_path(movie_dir) if not imdb_id: _log("ERROR", f"Movie path does not contain an IMDb id [imdb-tt...]: {movie_dir}") return ensure_movie_tables(conn) # Store/refresh movie row conn.execute( "INSERT INTO movies(imdb_id, movie_path, last_applied) VALUES(?,?,?) " "ON CONFLICT(imdb_id) DO UPDATE SET movie_path=excluded.movie_path, last_applied=excluded.last_applied", (imdb_id, str(movie_dir), _iso_now()), ) # Check if movie has video files video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") has_video_files = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_dir.iterdir()) if not has_video_files: _log("WARNING", f"No video files found in movie directory: {movie_dir}") conn.execute( "INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) " "ON CONFLICT(imdb_id) DO UPDATE SET has_video_file=excluded.has_video_file", (imdb_id, None, None, None, 0), ) conn.commit() return # For movies, we'll use simpler logic - primarily file mtime as dateadded # Get the newest video file mtime as our dateadded newest_mtime = None for f in movie_dir.iterdir(): if f.is_file() and f.suffix.lower() in video_exts: try: mtime = f.stat().st_mtime if newest_mtime is None or mtime > newest_mtime: newest_mtime = mtime except Exception: continue dateadded_iso = None if newest_mtime: try: dateadded_iso = datetime.fromtimestamp(newest_mtime, tz=timezone.utc).isoformat(timespec="seconds") except Exception: pass released_iso = None source = "file:mtime" # Store in database conn.execute( "INSERT INTO movie_dates(imdb_id, released, dateadded, source, has_video_file) VALUES(?,?,?,?,?) " "ON CONFLICT(imdb_id) DO UPDATE SET released=excluded.released, dateadded=excluded.dateadded, source=excluded.source, has_video_file=excluded.has_video_file", (imdb_id, released_iso, dateadded_iso, source, 1), ) # Create NFO if requested if config.manage_nfo: write_movie_nfo(movie_dir, imdb_id, released_iso, dateadded_iso, source, config.lock_metadata) # Update file times if requested if config.fix_dir_mtimes and dateadded_iso: set_movie_file_mtime(movie_dir, dateadded_iso) conn.commit() _log("INFO", f"Processed movie: {movie_dir.name}") # Thread-safe batching mechanism (updated for both TV and movies) class WebhookBatcher: def __init__(self): self.pending_items: Dict[str, Dict] = {} # key -> latest webhook data self.timers: Dict[str, threading.Timer] = {} # key -> timer self.processing: Set[str] = set() # Currently processing items self.lock = threading.Lock() self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent_series, thread_name_prefix="media-processor") def add_webhook(self, key: str, webhook_data: Dict, media_type: str): """Add webhook to batch, resetting timer if needed""" with self.lock: # Cancel existing timer if present if key in self.timers: self.timers[key].cancel() _log("DEBUG", f"Cancelled existing timer for {key}") # Store/update webhook data webhook_data['media_type'] = media_type # Add media type to data self.pending_items[key] = webhook_data _log("INFO", f"Batched {media_type} webhook for {key} (delay: {config.batch_delay}s)") # Create new timer timer = threading.Timer(config.batch_delay, self._process_item, args=[key]) self.timers[key] = timer timer.start() def _process_item(self, key: str): """Process an item after batch delay""" with self.lock: if key in self.processing or key not in self.pending_items: return self.processing.add(key) webhook_data = self.pending_items.pop(key) self.timers.pop(key, None) try: media_type = webhook_data.get('media_type', 'unknown') _log("INFO", f"Processing batched {media_type}: {key}") self.executor.submit(self._process_item_sync, key, webhook_data) except Exception as e: _log("ERROR", f"Error submitting {media_type} processing for {key}: {e}") with self.lock: self.processing.discard(key) def _process_item_sync(self, key: str, webhook_data: Dict): """Synchronous item processing with dedicated DB connection""" try: media_type = webhook_data.get('media_type', 'unknown') if media_type == 'tv': self._process_tv_sync(key, webhook_data) elif media_type == 'movie': self._process_movie_sync(key, webhook_data) else: _log("ERROR", f"Unknown media type: {media_type}") finally: with self.lock: self.processing.discard(key) def _process_tv_sync(self, imdb_id: str, webhook_data: Dict): """Process TV series""" try: series_info = webhook_data.get('series', {}) series_title = series_info.get("title", "Unknown") series_path_str = webhook_data.get('series_path') if not series_path_str: _log("ERROR", f"No series path found for {imdb_id}") return series_path = Path(series_path_str) if not series_path.exists(): _log("ERROR", f"Series path does not exist: {series_path}") return conn = None try: conn = db_connect(db_path) conn.execute("PRAGMA busy_timeout = 30000") conn.execute("PRAGMA journal_mode = WAL") class MockArgs: def __init__(self): self.manage_nfo = config.manage_nfo self.fix_dir_mtimes = config.fix_dir_mtimes self.lock_metadata = config.lock_metadata self.use_cache = True self.force_refresh = False self.skip_if_current = False self.debug = config.debug self.update_video_mtimes = True _log("INFO", f"Processing TV series: {series_title} at {series_path}") apply_series(series_path, MockArgs(), conn) _log("INFO", f"Successfully processed batched TV series: {series_title}") except Exception as e: _log("ERROR", f"Error processing TV series {series_title}: {e}") finally: if conn: try: conn.close() except: pass except Exception as e: _log("ERROR", f"Error in TV processing: {e}") def _process_movie_sync(self, imdb_id: str, webhook_data: Dict): """Process movie""" try: movie_info = webhook_data.get('movie', {}) movie_title = movie_info.get("title", "Unknown") movie_path_str = webhook_data.get('movie_path') if not movie_path_str: _log("ERROR", f"No movie path found for {imdb_id}") return movie_path = Path(movie_path_str) if not movie_path.exists(): _log("ERROR", f"Movie path does not exist: {movie_path}") return conn = None try: conn = db_connect(db_path) conn.execute("PRAGMA busy_timeout = 30000") conn.execute("PRAGMA journal_mode = WAL") _log("INFO", f"Processing movie: {movie_title} at {movie_path}") apply_movie(movie_path, conn) _log("INFO", f"Successfully processed batched movie: {movie_title}") except Exception as e: _log("ERROR", f"Error processing movie {movie_title}: {e}") finally: if conn: try: conn.close() except: pass except Exception as e: _log("ERROR", f"Error in movie processing: {e}") def get_status(self) -> Dict: """Get current batching status""" with self.lock: return { "pending_items": list(self.pending_items.keys()), "processing_items": list(self.processing), "pending_count": len(self.pending_items), "processing_count": len(self.processing), "executor_active_threads": getattr(self.executor, '_threads', 0) } # Global batcher instance batcher = WebhookBatcher() def find_series_path_from_sonarr_path(sonarr_path: str) -> Optional[Path]: """Convert Sonarr's full path to container path""" if not sonarr_path: return None if sonarr_path.startswith("/mnt/unionfs/Media/TV/"): container_path = sonarr_path.replace("/mnt/unionfs/Media/TV/", "/media/") path = Path(container_path) if path.exists(): _log("INFO", f"Found series path via Sonarr path: {path}") return path return None def find_movie_path_from_radarr_path(radarr_path: str) -> Optional[Path]: """Convert Radarr's full path (folderPath or path) to container path""" if not radarr_path: return None if radarr_path.startswith("/mnt/unionfs/Media/Movies/"): container_path = radarr_path.replace("/mnt/unionfs/Media/Movies/", "/media/") path = Path(container_path) if path.exists(): _log("INFO", f"Found movie path via Radarr path: {path}") return path return None def find_series_path(series_title: str, imdb_id: str, sonarr_path: str = None) -> Optional[Path]: """Find the series directory based on title, IMDb ID, and optional Sonarr path""" if sonarr_path: series_path = find_series_path_from_sonarr_path(sonarr_path) if series_path: return series_path if imdb_id and not imdb_id.startswith("tt"): imdb_id = f"tt{imdb_id}" _log("DEBUG", f"Searching for series: {series_title} with IMDb ID: {imdb_id}") for media_path in config.tv_paths: if not media_path.exists(): _log("WARNING", f"TV path does not exist: {media_path}") continue if imdb_id: pattern = str(media_path / f"*[imdb-{imdb_id}]*") matches = glob.glob(pattern) if matches: found_path = Path(matches[0]) _log("INFO", f"Found series directory by IMDb ID: {found_path}") return found_path if series_title: series_title_clean = series_title.lower().replace(" ", "").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("-", "").replace(":", "") if series_title_clean in item_clean: _log("INFO", f"Found series directory by title: {item}") return item _log("WARNING", f"Could not find directory for series: {series_title} ({imdb_id})") return None def find_movie_path(movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]: """Find the movie directory based on title, IMDb ID, and optional Radarr path""" if radarr_path: movie_path = find_movie_path_from_radarr_path(radarr_path) if movie_path: return movie_path if imdb_id and not imdb_id.startswith("tt"): imdb_id = f"tt{imdb_id}" _log("DEBUG", f"Searching for movie: {movie_title} with IMDb ID: {imdb_id}") for media_path in config.movie_paths: if not media_path.exists(): _log("WARNING", f"Movie path does not exist: {media_path}") continue if imdb_id: pattern = str(media_path / f"*[imdb-{imdb_id}]*") matches = glob.glob(pattern) if matches: found_path = Path(matches[0]) _log("INFO", f"Found movie directory by IMDb ID: {found_path}") return found_path if movie_title: movie_title_clean = movie_title.lower().replace(" ", "").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("-", "").replace(":", "") if movie_title_clean in item_clean: _log("INFO", f"Found movie directory by title: {item}") return item _log("WARNING", f"Could not find directory for movie: {movie_title} ({imdb_id})") return None async def process_sonarr_import(webhook_data: SonarrWebhook): """Process a Sonarr import webhook by adding to batch""" try: if not webhook_data.series: _log("WARNING", f"No series data in {webhook_data.eventType} webhook") return series_info = webhook_data.series series_title = series_info.get("title", "Unknown") imdb_id = series_info.get("imdbId", "") sonarr_path = series_info.get("path", "") if imdb_id: imdb_id = imdb_id.replace("tt", "").strip() if imdb_id: imdb_id = f"tt{imdb_id}" if not imdb_id: _log("ERROR", f"No IMDb ID found for series: {series_title}") return _log("INFO", f"Received webhook for series: {series_title} (IMDb: {imdb_id})") series_path = find_series_path(series_title, imdb_id, sonarr_path) if not series_path: _log("ERROR", f"Could not find series directory for: {series_title} ({imdb_id})") return webhook_dict = { 'series': series_info, 'series_path': str(series_path), 'event_type': webhook_data.eventType } batcher.add_webhook(imdb_id, webhook_dict, 'tv') except Exception as e: _log("ERROR", f"Error processing Sonarr webhook: {e}") raise async def process_radarr_import(webhook_data: RadarrWebhook): """Process a Radarr import webhook by adding to batch""" try: if not webhook_data.movie: _log("WARNING", f"No movie data in {webhook_data.eventType} webhook") return movie_info = webhook_data.movie movie_title = movie_info.get("title", "Unknown") # Prefer folderPath for Rename payloads; fall back to path radarr_path = movie_info.get("folderPath") or movie_info.get("path", "") # imdbId may be missing on Rename; fall back to remoteMovie or parse from folder imdb_id = (movie_info.get("imdbId") or (webhook_data.remoteMovie or {}).get("imdbId") or "").strip() if imdb_id: imdb_id = f"tt{imdb_id.replace('tt','')}" movie_path = find_movie_path(movie_title, imdb_id, radarr_path) if not imdb_id and movie_path: # Try to parse from the resolved folder name parsed = parse_imdb_from_movie_path(movie_path) if parsed: imdb_id = parsed if not imdb_id: _log("WARNING", f"No IMDb ID available for movie '{movie_title}'. Proceeding by path only.") _log("INFO", f"Received webhook for movie: {movie_title} (IMDb: {imdb_id or 'unknown'})") if getattr(webhook_data, "renamedMovieFiles", None): _log("INFO", f"Renamed {len(webhook_data.renamedMovieFiles)} file(s) for {movie_title}") if not movie_path: _log("ERROR", f"Could not find movie directory for: {movie_title} ({imdb_id or 'unknown'})") return webhook_dict = { 'movie': movie_info, 'movie_path': str(movie_path), 'event_type': webhook_data.eventType } batcher.add_webhook(imdb_id or str(movie_path), webhook_dict, 'movie') except Exception as e: _log("ERROR", f"Error processing Radarr webhook: {e}") raise @app.post("/webhook/sonarr") async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks): """Handle Sonarr webhooks""" try: webhook_data = await request.json() _log("DEBUG", f"Raw Sonarr webhook payload: {webhook_data}") webhook = SonarrWebhook(**webhook_data) _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"} await process_sonarr_import(webhook) return {"status": "accepted", "message": "Sonarr webhook queued for batched processing"} except Exception as e: _log("ERROR", f"Failed to parse Sonarr webhook: {e}") raise HTTPException(status_code=422, detail=f"Invalid Sonarr webhook format: {e}") @app.post("/webhook/radarr") async def radarr_webhook(request: Request, background_tasks: BackgroundTasks): """Handle Radarr webhooks""" try: webhook_data = await request.json() _log("DEBUG", f"Raw Radarr webhook payload: {webhook_data}") webhook = RadarrWebhook(**webhook_data) _log("INFO", f"Received Radarr webhook: {webhook.eventType}") # Allow common movie-file events; Rename payload has folderPath/renamedMovieFiles if webhook.eventType not in ["Download", "Upgrade", "Rename", "MovieFileDelete", "MovieDelete"]: return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"} await process_radarr_import(webhook) return {"status": "accepted", "message": "Radarr webhook queued for batched processing"} except Exception as e: _log("ERROR", f"Failed to parse Radarr webhook: {e}") raise HTTPException(status_code=422, detail=f"Invalid Radarr webhook format: {e}") @app.get("/health") async def health_check() -> HealthResponse: """Health check endpoint""" uptime = datetime.now(timezone.utc) - start_time # Check database connectivity try: conn = db_connect(db_path) conn.execute("PRAGMA busy_timeout = 5000") conn.execute("SELECT 1").fetchone() conn.close() db_status = "healthy" except Exception as e: db_status = f"error: {e}" return HealthResponse( status="healthy" if db_status == "healthy" else "degraded", version="1.0.0", uptime=str(uptime), database_status=db_status ) @app.get("/stats") async def get_stats(): """Get database statistics""" try: conn = db_connect(db_path) conn.execute("PRAGMA busy_timeout = 5000") # Ensure movie tables exist ensure_movie_tables(conn) # Get TV series count tv_series_count = conn.execute("SELECT COUNT(*) FROM series").fetchone()[0] # Get TV episode count tv_episode_count = conn.execute("SELECT COUNT(*) FROM episode_dates").fetchone()[0] # Get TV episodes with video files tv_with_files = conn.execute("SELECT COUNT(*) FROM episode_dates WHERE has_video_file = 1").fetchone()[0] # Get movie count movie_count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0] # Get movies with video files movies_with_files = conn.execute("SELECT COUNT(*) FROM movie_dates WHERE has_video_file = 1").fetchone()[0] # Get TV source breakdown tv_sources = conn.execute(""" SELECT source, COUNT(*) as count FROM episode_dates WHERE has_video_file = 1 GROUP BY source ORDER BY count DESC """).fetchall() # Get movie source breakdown movie_sources = conn.execute(""" SELECT source, COUNT(*) as count FROM movie_dates WHERE has_video_file = 1 GROUP BY source ORDER BY count DESC """).fetchall() conn.close() return { "tv_series_count": tv_series_count, "tv_episode_count": tv_episode_count, "tv_episodes_with_files": tv_with_files, "movie_count": movie_count, "movies_with_files": movies_with_files, "tv_sources": dict(tv_sources), "movie_sources": dict(movie_sources) } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/batch/status") async def get_batch_status(): """Get current batching status""" return batcher.get_status() def iterate_movie_paths(): """Find all movie directories in configured paths""" movies = [] for movie_path in config.movie_paths: if not movie_path.exists(): continue for item in movie_path.iterdir(): if is_movie_dir(item): movies.append(item) return sorted(movies) def run_manual_scan_sync(scan_paths: List[Path], scan_type: str = "both"): """Synchronous function for manual scan - runs in executor""" from media_date_cache import iterate_target_path class MockArgs: def __init__(self): self.manage_nfo = config.manage_nfo self.fix_dir_mtimes = config.fix_dir_mtimes self.lock_metadata = config.lock_metadata self.use_cache = True self.force_refresh = False self.skip_if_current = True # Skip current files in manual scan self.debug = config.debug self.update_video_mtimes = True conn = None try: conn = db_connect(db_path) conn.execute("PRAGMA busy_timeout = 30000") conn.execute("PRAGMA journal_mode = WAL") ensure_movie_tables(conn) for scan_path in scan_paths: _log("INFO", f"Starting manual scan of: {scan_path}") if scan_type in ["both", "tv"]: # Scan TV series tv_targets = iterate_target_path(scan_path) for series_dir in tv_targets: try: apply_series(series_dir, MockArgs(), conn) except Exception as e: _log("ERROR", f"Failed processing TV series {series_dir}: {e}") if scan_type in ["both", "movies"]: # Scan movies if scan_path in config.movie_paths: for item in scan_path.iterdir(): if is_movie_dir(item): try: apply_movie(item, conn) except Exception as e: _log("ERROR", f"Failed processing movie {item}: {e}") _log("INFO", f"Completed manual scan of: {scan_path}") finally: if conn: try: conn.close() except: pass @app.post("/manual/scan") async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"): """Trigger a manual library scan""" if path: scan_paths = [Path(path)] else: # Scan both TV and movie paths scan_paths = list(config.tv_paths) + list(config.movie_paths) # Validate scan_type if scan_type not in ["both", "tv", "movies"]: raise HTTPException(status_code=400, detail="scan_type must be 'both', 'tv', or 'movies'") # Run in executor async def run_scan(): await asyncio.get_event_loop().run_in_executor(None, run_manual_scan_sync, scan_paths, scan_type) background_tasks.add_task(run_scan) return {"status": "started", "message": f"Manual {scan_type} scan started for {len(scan_paths)} paths"} @app.post("/manual/scan/tv") async def manual_scan_tv(background_tasks: BackgroundTasks, path: Optional[str] = None): """Trigger a manual TV library scan""" return await manual_scan(background_tasks, path, "tv") @app.post("/manual/scan/movies") async def manual_scan_movies(background_tasks: BackgroundTasks, path: Optional[str] = None): """Trigger a manual movie library scan""" return await manual_scan(background_tasks, path, "movies") @app.get("/test/path/{media_title}") async def test_path_matching(media_title: str, imdb_id: Optional[str] = None, media_type: str = "tv", app_path: Optional[str] = None): """Test endpoint to debug path matching""" if media_type == "tv": result = find_series_path(media_title, imdb_id or "", app_path or "") paths = config.tv_paths elif media_type == "movie": result = find_movie_path(media_title, imdb_id or "", app_path or "") paths = config.movie_paths else: raise HTTPException(status_code=400, detail="media_type must be 'tv' or 'movie'") return { "media_title": media_title, "media_type": media_type, "imdb_id": imdb_id, "app_path": app_path, "search_paths": [str(p) for p in paths], "found_path": str(result) if result else None, "exists": result.exists() if result else False } if __name__ == "__main__": # Enable debug logging if requested if config.debug: import logging logging.basicConfig(level=logging.DEBUG) os.environ["DEBUG"] = "true" _log("INFO", "Starting Media Date Cache Webhook Server with TV and Movie support") _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 path: {db_path}") _log("INFO", f"Manage NFO: {config.manage_nfo}") _log("INFO", f"Fix dir mtimes: {config.fix_dir_mtimes}") _log("INFO", f"Lock metadata: {config.lock_metadata}") _log("INFO", f"Batch delay: {config.batch_delay}s") _log("INFO", f"Max concurrent items: {config.max_concurrent_series}") uvicorn.run( "webhook_server:app", host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), reload=False )