Files
nfoguard/webhook_server.py
T
2025-08-30 10:49:21 -04:00

878 lines
33 KiB
Python

#!/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 = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
"<movie>",
f" <id>{imdb_id}</id>",
f" <uniqueid type=\"imdb\" default=\"true\">{imdb_id}</uniqueid>",
]
if released:
xml.append(f" <premiered>{released.split('T')[0]}</premiered>")
xml.append(f" <year>{released.split('-')[0]}</year>")
if dateadded:
xml.append(f" <dateadded>{dateadded}</dateadded>")
if lock_metadata:
xml.append(" <lockdata>true</lockdata>")
if source:
xml.append(f" <!-- source: {source} -->")
xml.append(f" <!-- managed by media-date-cache -->")
xml.append("</movie>\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
)