Files
nfoguard/nfoguard.py
T
2025-09-08 10:05:04 -04:00

858 lines
33 KiB
Python

#!/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
)