323 lines
14 KiB
Python
323 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
FastAPI routes for NFOGuard
|
|
"""
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
# Import models
|
|
from .models import SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest
|
|
|
|
# Import core utilities
|
|
from core.logging import _log
|
|
from core.database import NFOGuardDatabase
|
|
from core.fs_cache import fs_cache, clear_all_caches
|
|
from core.xml_cache import xml_cache
|
|
from core.batch_operations import optimize_library_scan
|
|
|
|
|
|
async def _read_payload(request: Request) -> Optional[Dict[str, Any]]:
|
|
"""Read and validate JSON payload from webhook"""
|
|
try:
|
|
content_type = request.headers.get("content-type", "")
|
|
if "application/json" not in content_type.lower():
|
|
_log("WARNING", f"Invalid content type: {content_type}")
|
|
return None
|
|
|
|
body = await request.body()
|
|
if not body:
|
|
_log("WARNING", "Empty request body")
|
|
return None
|
|
|
|
payload = json.loads(body.decode('utf-8'))
|
|
_log("DEBUG", f"Raw payload: {json.dumps(payload, indent=2)}")
|
|
return payload
|
|
|
|
except json.JSONDecodeError as e:
|
|
_log("ERROR", f"Invalid JSON payload: {e}")
|
|
return None
|
|
except Exception as e:
|
|
_log("ERROR", f"Error reading payload: {e}")
|
|
return None
|
|
|
|
|
|
def register_routes(
|
|
app: FastAPI,
|
|
tv_processor,
|
|
movie_processor,
|
|
batcher,
|
|
db: NFOGuardDatabase,
|
|
start_time: datetime,
|
|
version: str
|
|
):
|
|
"""Register all routes with the FastAPI app"""
|
|
|
|
@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:
|
|
_log("WARNING", "No series data in Sonarr webhook")
|
|
return {"status": "error", "message": "No series data"}
|
|
|
|
# Process the webhook using the batcher
|
|
await batcher.queue_sonarr_webhook(payload)
|
|
return {"status": "queued", "message": f"Sonarr {webhook.eventType} webhook queued for processing"}
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Sonarr webhook error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(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"]:
|
|
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
|
|
|
if not webhook.movie:
|
|
_log("WARNING", "No movie data in Radarr webhook")
|
|
return {"status": "error", "message": "No movie data"}
|
|
|
|
# Process the webhook using the batcher
|
|
await batcher.queue_radarr_webhook(payload)
|
|
return {"status": "queued", "message": f"Radarr {webhook.eventType} webhook queued for processing"}
|
|
|
|
except Exception as e:
|
|
_log("ERROR", f"Radarr webhook error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
|
|
|
|
@app.get("/health")
|
|
async def health() -> HealthResponse:
|
|
"""Health check endpoint with database status"""
|
|
uptime = datetime.now(timezone.utc) - start_time
|
|
|
|
# Check NFOGuard database
|
|
try:
|
|
with db.get_connection() as conn:
|
|
conn.execute("SELECT 1").fetchone()
|
|
db_status = "healthy"
|
|
except Exception as e:
|
|
db_status = f"error: {e}"
|
|
|
|
# Check Radarr database if available
|
|
radarr_db_health = None
|
|
overall_status = "healthy" if db_status == "healthy" else "degraded"
|
|
|
|
# Get Radarr client with database access from movie processor
|
|
try:
|
|
if hasattr(movie_processor, 'radarr') and movie_processor.radarr:
|
|
if hasattr(movie_processor.radarr, 'db_client') and movie_processor.radarr.db_client:
|
|
radarr_test = movie_processor.radarr.db_client.test_connection()
|
|
radarr_db_health = {
|
|
"radarr_db_available": radarr_test["success"],
|
|
"radarr_db_path": radarr_test.get("db_path"),
|
|
"radarr_db_error": radarr_test.get("error")
|
|
}
|
|
if not radarr_test["success"]:
|
|
overall_status = "degraded"
|
|
except Exception as e:
|
|
_log("DEBUG", f"Radarr database health check failed: {e}")
|
|
|
|
return HealthResponse(
|
|
status=overall_status,
|
|
version=version,
|
|
uptime=str(uptime).split('.')[0], # Remove microseconds
|
|
database_status=db_status,
|
|
radarr_database=radarr_db_health
|
|
)
|
|
|
|
@app.get("/stats")
|
|
async def get_stats():
|
|
"""Get processing statistics"""
|
|
return {
|
|
"message": "Stats endpoint - implementation needed",
|
|
"uptime": str(datetime.now(timezone.utc) - start_time).split('.')[0]
|
|
}
|
|
|
|
@app.get("/batch/status")
|
|
async def batch_status():
|
|
"""Get current batch processing status"""
|
|
return {
|
|
"pending_items": batcher.get_pending_count(),
|
|
"processing_items": batcher.get_processing_count(),
|
|
"total_queued": batcher.get_pending_count() + batcher.get_processing_count()
|
|
}
|
|
|
|
@app.post("/manual/scan")
|
|
async def manual_scan(background_tasks: BackgroundTasks):
|
|
"""Trigger manual library scan"""
|
|
try:
|
|
_log("INFO", "Manual scan triggered via API")
|
|
|
|
# Add background task to scan all libraries
|
|
def scan_libraries():
|
|
total_processed = 0
|
|
|
|
# Scan TV libraries
|
|
for tv_path in config.tv_paths:
|
|
if tv_path.exists():
|
|
_log("INFO", f"Scanning TV library: {tv_path}")
|
|
for series_dir in tv_path.iterdir():
|
|
if series_dir.is_dir() and "[imdb-" in series_dir.name:
|
|
try:
|
|
tv_processor.process_series(series_dir)
|
|
total_processed += 1
|
|
except Exception as e:
|
|
_log("ERROR", f"Error processing series {series_dir.name}: {e}")
|
|
|
|
# Scan Movie libraries
|
|
for movie_path in config.movie_paths:
|
|
if movie_path.exists():
|
|
_log("INFO", f"Scanning movie library: {movie_path}")
|
|
for movie_dir in movie_path.iterdir():
|
|
if movie_dir.is_dir():
|
|
try:
|
|
movie_processor.process_movie(movie_dir)
|
|
total_processed += 1
|
|
except Exception as e:
|
|
_log("ERROR", f"Error processing movie {movie_dir.name}: {e}")
|
|
|
|
_log("INFO", f"Manual scan completed: {total_processed} items processed")
|
|
|
|
background_tasks.add_task(scan_libraries)
|
|
return {"status": "started", "message": "Manual scan initiated", "note": "Scan running in background - check logs for progress"}
|
|
except Exception as e:
|
|
_log("ERROR", f"Manual scan failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Manual scan failed: {str(e)}")
|
|
|
|
@app.post("/tv/scan-season")
|
|
async def scan_tv_season(request: TVSeasonRequest, background_tasks: BackgroundTasks):
|
|
"""Scan specific TV season"""
|
|
try:
|
|
_log("INFO", f"TV season scan requested: {request.series_path}/{request.season_name}")
|
|
return {"status": "started", "message": f"Scanning season {request.season_name}"}
|
|
except Exception as e:
|
|
_log("ERROR", f"TV season scan failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Season scan failed: {str(e)}")
|
|
|
|
@app.post("/tv/scan-episode")
|
|
async def scan_tv_episode(request: TVEpisodeRequest, background_tasks: BackgroundTasks):
|
|
"""Scan specific TV episode"""
|
|
try:
|
|
_log("INFO", f"TV episode scan requested: {request.series_path}/{request.season_name}/{request.episode_name}")
|
|
return {"status": "started", "message": f"Scanning episode {request.episode_name}"}
|
|
except Exception as e:
|
|
_log("ERROR", f"TV episode scan failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Episode scan failed: {str(e)}")
|
|
|
|
# Debug endpoints
|
|
@app.get("/debug/movie/{imdb_id}")
|
|
async def debug_movie(imdb_id: str):
|
|
"""Debug movie processing for specific IMDb ID"""
|
|
return {"message": f"Debug info for movie {imdb_id} - implementation needed"}
|
|
|
|
@app.get("/debug/movie/{imdb_id}/history")
|
|
async def debug_movie_history(imdb_id: str):
|
|
"""Debug movie processing history"""
|
|
return {"message": f"Debug history for movie {imdb_id} - implementation needed"}
|
|
|
|
@app.get("/debug/movie/{imdb_id}/priority")
|
|
async def debug_movie_priority(imdb_id: str):
|
|
"""Debug movie priority processing"""
|
|
return {"message": f"Debug priority for movie {imdb_id} - implementation needed"}
|
|
|
|
@app.get("/debug/tmdb/{imdb_id}")
|
|
async def debug_tmdb(imdb_id: str):
|
|
"""Debug TMDB API calls for movie"""
|
|
return {"message": f"Debug TMDB for movie {imdb_id} - implementation needed"}
|
|
|
|
# Test endpoints
|
|
@app.post("/test/bulk-update")
|
|
async def test_bulk_update():
|
|
"""Test bulk update functionality"""
|
|
return {"message": "Test bulk update - implementation needed"}
|
|
|
|
@app.post("/test/movie-scan")
|
|
async def test_movie_scan():
|
|
"""Test movie scan functionality"""
|
|
return {"message": "Test movie scan - implementation needed"}
|
|
|
|
@app.post("/bulk/update")
|
|
async def bulk_update():
|
|
"""Bulk update functionality"""
|
|
return {"message": "Bulk update - implementation needed"}
|
|
|
|
# Performance monitoring endpoints
|
|
@app.get("/cache/stats")
|
|
async def cache_stats():
|
|
"""Get comprehensive cache performance statistics"""
|
|
fs_stats = fs_cache.get_cache_stats()
|
|
xml_stats = xml_cache.get_cache_stats()
|
|
|
|
# Combine stats
|
|
combined_stats = {
|
|
"filesystem_cache": fs_stats,
|
|
"xml_processing_cache": xml_stats,
|
|
"overall_hit_rate": round(
|
|
((fs_stats["cache_hits"] + xml_stats["xml_cache_hits"]) /
|
|
(fs_stats["cache_hits"] + fs_stats["cache_misses"] +
|
|
xml_stats["xml_cache_hits"] + xml_stats["xml_cache_misses"]) * 100
|
|
) if (fs_stats["cache_hits"] + fs_stats["cache_misses"] +
|
|
xml_stats["xml_cache_hits"] + xml_stats["xml_cache_misses"]) > 0 else 0, 2
|
|
)
|
|
}
|
|
|
|
return combined_stats
|
|
|
|
@app.post("/cache/clear")
|
|
async def clear_cache():
|
|
"""Clear all caches"""
|
|
clear_all_caches()
|
|
return {"status": "success", "message": "All caches cleared"}
|
|
|
|
@app.post("/scan/optimized")
|
|
async def optimized_scan(background_tasks: BackgroundTasks):
|
|
"""Trigger optimized library scan using batch operations"""
|
|
try:
|
|
_log("INFO", "Optimized scan triggered via API")
|
|
|
|
def run_optimized_scan():
|
|
# Combine all library paths
|
|
all_paths = list(config.tv_paths) + list(config.movie_paths)
|
|
existing_paths = [p for p in all_paths if p.exists()]
|
|
|
|
if not existing_paths:
|
|
_log("WARNING", "No valid library paths found")
|
|
return
|
|
|
|
# Run optimized scan
|
|
results = optimize_library_scan(existing_paths)
|
|
_log("INFO", f"Optimized scan complete: {results}")
|
|
|
|
background_tasks.add_task(run_optimized_scan)
|
|
return {
|
|
"status": "started",
|
|
"message": "Optimized library scan initiated",
|
|
"note": "Uses batch operations and caching for improved performance"
|
|
}
|
|
except Exception as e:
|
|
_log("ERROR", f"Optimized scan failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Optimized scan failed: {str(e)}") |