|
|
|
@@ -0,0 +1,877 @@
|
|
|
|
|
#!/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
|
|
|
|
|
)
|
|
|
|
|
|