refactor: split up from mono app to more modular design
This commit is contained in:
+870
@@ -0,0 +1,870 @@
|
||||
"""
|
||||
FastAPI routes for NFOGuard - extracted from main nfoguard.py for modular architecture
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import HTTPException, BackgroundTasks, Request
|
||||
from typing import Optional
|
||||
|
||||
# Import models
|
||||
from api.models import SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Helper Functions
|
||||
# ---------------------------
|
||||
|
||||
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:
|
||||
print(f"ERROR: Failed to read webhook payload: {e}") # Using print since _log is not available
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Route Handlers
|
||||
# ---------------------------
|
||||
|
||||
async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict):
|
||||
"""Handle Sonarr webhooks"""
|
||||
tv_processor = dependencies["tv_processor"]
|
||||
batcher = dependencies["batcher"]
|
||||
config = dependencies["config"]
|
||||
|
||||
try:
|
||||
payload = await _read_payload(request)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty Sonarr payload")
|
||||
|
||||
webhook = SonarrWebhook(**payload)
|
||||
print(f"INFO: 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:
|
||||
print(f"ERROR: 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:
|
||||
print(f"ERROR: Could not find series directory: {series_title} ({imdb_id})")
|
||||
return {"status": "error", "reason": "Series directory not found"}
|
||||
|
||||
# Add to batch queue with TV-prefixed key to avoid movie conflicts
|
||||
tv_batch_key = f"tv:{imdb_id}"
|
||||
webhook_dict = {
|
||||
'path': str(series_path),
|
||||
'series_info': series_info,
|
||||
'event_type': webhook.eventType,
|
||||
'episodes': webhook.episodes or [], # Include episode data for targeted processing
|
||||
'processing_mode': config.tv_webhook_processing_mode
|
||||
}
|
||||
batcher.add_webhook(tv_batch_key, webhook_dict, 'tv')
|
||||
|
||||
return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Sonarr webhook error: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
|
||||
|
||||
|
||||
async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict):
|
||||
"""Handle Radarr webhooks"""
|
||||
path_mapper = dependencies["path_mapper"]
|
||||
batcher = dependencies["batcher"]
|
||||
|
||||
try:
|
||||
payload = await _read_payload(request)
|
||||
print(f"INFO: Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
|
||||
print(f"DEBUG: Full Radarr webhook payload: {payload}")
|
||||
|
||||
# Filter supported event types (same as Sonarr: Download, Upgrade, Rename)
|
||||
event_type = payload.get('eventType', '')
|
||||
if event_type not in ["Download", "Upgrade", "Rename"]:
|
||||
return {"status": "ignored", "reason": f"Event type {event_type} not processed"}
|
||||
|
||||
# Extract movie info
|
||||
movie_data = payload.get("movie", {})
|
||||
if not movie_data:
|
||||
print("WARNING: No movie data in Radarr webhook")
|
||||
return {"status": "error", "message": "No movie data"}
|
||||
|
||||
# Get IMDb ID for batching key
|
||||
imdb_id = movie_data.get("imdbId", "").lower()
|
||||
if not imdb_id:
|
||||
print("WARNING: No IMDb ID in Radarr webhook movie data")
|
||||
return {"status": "error", "message": "No IMDb ID"}
|
||||
|
||||
# Get movie path and map it
|
||||
movie_path = movie_data.get("folderPath") or movie_data.get("path", "")
|
||||
if not movie_path:
|
||||
print("ERROR: No movie path in Radarr webhook")
|
||||
return {"status": "error", "message": "No movie path provided"}
|
||||
|
||||
# Map the path to container path
|
||||
container_path = path_mapper.radarr_path_to_container_path(movie_path)
|
||||
print(f"DEBUG: Mapped Radarr path {movie_path} -> {container_path}")
|
||||
|
||||
# CRITICAL: Verify the mapped path actually exists
|
||||
if not Path(container_path).exists():
|
||||
print(f"ERROR: RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
|
||||
print(f"ERROR: This prevents processing wrong movies due to path mapping issues")
|
||||
return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"}
|
||||
|
||||
# Verify the path contains the expected IMDb ID
|
||||
if imdb_id not in container_path.lower():
|
||||
print(f"WARNING: IMDb ID {imdb_id} not found in container path {container_path}")
|
||||
|
||||
# Create movie-specific webhook data with proper path validation
|
||||
movie_webhook_data = {
|
||||
'path': container_path, # Use verified container path
|
||||
'movie_info': movie_data,
|
||||
'event_type': payload.get('eventType'),
|
||||
'original_payload': payload
|
||||
}
|
||||
|
||||
# Add to batch queue with movie-prefixed key to avoid TV conflicts
|
||||
movie_batch_key = f"movie:{imdb_id}"
|
||||
print(f"DEBUG: Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}")
|
||||
batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie")
|
||||
|
||||
return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Radarr webhook error: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
async def health(dependencies: dict) -> HealthResponse:
|
||||
"""Health check endpoint with Radarr database status"""
|
||||
db = dependencies["db"]
|
||||
movie_processor = dependencies["movie_processor"]
|
||||
start_time = dependencies["start_time"]
|
||||
version = dependencies["version"]
|
||||
|
||||
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:
|
||||
radarr_client = movie_processor.radarr
|
||||
if hasattr(radarr_client, 'db_client') and radarr_client.db_client:
|
||||
try:
|
||||
radarr_db_health = radarr_client.db_client.health_check()
|
||||
if radarr_db_health["status"] != "healthy":
|
||||
overall_status = "degraded"
|
||||
except Exception as e:
|
||||
radarr_db_health = {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
}
|
||||
overall_status = "degraded"
|
||||
except Exception as e:
|
||||
# If movie processor isn't available, skip database health check
|
||||
print(f"DEBUG: Skipping Radarr database health check: {e}")
|
||||
|
||||
return HealthResponse(
|
||||
status=overall_status,
|
||||
version=version,
|
||||
uptime=str(uptime),
|
||||
database_status=db_status,
|
||||
radarr_database=radarr_db_health
|
||||
)
|
||||
|
||||
|
||||
async def get_stats(dependencies: dict):
|
||||
"""Get database statistics"""
|
||||
db = dependencies["db"]
|
||||
try:
|
||||
return db.get_stats()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def batch_status(dependencies: dict):
|
||||
"""Get batch queue status"""
|
||||
batcher = dependencies["batcher"]
|
||||
return batcher.get_status()
|
||||
|
||||
|
||||
async def debug_movie_import_date(imdb_id: str, dependencies: dict):
|
||||
"""Debug endpoint to analyze movie import date detection"""
|
||||
movie_processor = dependencies["movie_processor"]
|
||||
|
||||
try:
|
||||
if not imdb_id.startswith("tt"):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
print(f"INFO: === DEBUG MOVIE IMPORT DATE: {imdb_id} ===")
|
||||
|
||||
if not (os.environ.get("RADARR_URL") and os.environ.get("RADARR_API_KEY")):
|
||||
return {
|
||||
"error": "Radarr not configured",
|
||||
"imdb_id": imdb_id,
|
||||
"radarr_configured": False
|
||||
}
|
||||
|
||||
# Create Radarr client
|
||||
from clients.radarr_client import RadarrClient
|
||||
radarr_client = RadarrClient(
|
||||
os.environ.get("RADARR_URL"),
|
||||
os.environ.get("RADARR_API_KEY")
|
||||
)
|
||||
|
||||
# Look up movie
|
||||
movie_obj = radarr_client.movie_by_imdb(imdb_id)
|
||||
if not movie_obj:
|
||||
return {
|
||||
"error": f"Movie not found in Radarr for IMDb ID {imdb_id}",
|
||||
"imdb_id": imdb_id,
|
||||
"radarr_configured": True,
|
||||
"movie_found": False
|
||||
}
|
||||
|
||||
movie_id = movie_obj.get("id")
|
||||
movie_title = movie_obj.get("title")
|
||||
|
||||
print(f"INFO: Found movie: {movie_title} (Radarr ID: {movie_id})")
|
||||
|
||||
# Test the FULL movie processing pipeline (not just database lookup)
|
||||
print(f"INFO: === TESTING FULL MOVIE PROCESSING PIPELINE ===")
|
||||
|
||||
# Create a dummy path for testing the decision logic
|
||||
dummy_path = Path("/tmp/test")
|
||||
|
||||
try:
|
||||
# Use the global movie processor instance to test full decision logic
|
||||
if movie_processor:
|
||||
# First check external clients configuration
|
||||
print(f"INFO: === CHECKING EXTERNAL CLIENTS CONFIG ===")
|
||||
try:
|
||||
tmdb_key = os.environ.get("TMDB_API_KEY", "")
|
||||
print(f"INFO: TMDB API Key configured: {'✅ YES' if tmdb_key else '❌ NO'}")
|
||||
if tmdb_key:
|
||||
print(f"INFO: TMDB API Key length: {len(tmdb_key)} chars")
|
||||
|
||||
# Check if external clients exist
|
||||
external_clients_available = hasattr(movie_processor, 'external_clients') and movie_processor.external_clients
|
||||
print(f"INFO: External clients initialized: {'✅ YES' if external_clients_available else '❌ NO'}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Error checking external clients config: {e}")
|
||||
|
||||
# Test the full decision logic (including TMDB fallback)
|
||||
final_date, final_source, released = movie_processor._decide_movie_dates(
|
||||
imdb_id, dummy_path, should_query=True, existing=None
|
||||
)
|
||||
|
||||
print(f"INFO: === FULL PIPELINE RESULT ===")
|
||||
print(f"INFO: Final date: {final_date}")
|
||||
print(f"INFO: Final source: {final_source}")
|
||||
print(f"INFO: Released (theater): {released}")
|
||||
|
||||
return {
|
||||
"imdb_id": imdb_id,
|
||||
"radarr_configured": True,
|
||||
"movie_found": True,
|
||||
"movie_title": movie_title,
|
||||
"movie_id": movie_id,
|
||||
"full_pipeline_test": {
|
||||
"final_date": final_date,
|
||||
"final_source": final_source,
|
||||
"theater_release": released,
|
||||
"decision_logic": "✅ TESTED FULL PIPELINE INCLUDING TMDB FALLBACK"
|
||||
},
|
||||
"database_only_test": {
|
||||
"radarr_db_result": radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True),
|
||||
"note": "This is just the database part - fallback happens in full pipeline"
|
||||
},
|
||||
"debug_info": {
|
||||
"radarr_url": os.environ.get("RADARR_URL"),
|
||||
"movie_digital_release": movie_obj.get("digitalRelease"),
|
||||
"movie_in_cinemas": movie_obj.get("inCinemas"),
|
||||
"movie_physical_release": movie_obj.get("physicalRelease"),
|
||||
"movie_folder_path": movie_obj.get("folderPath")
|
||||
}
|
||||
}
|
||||
else:
|
||||
print("ERROR: Movie processor not available - testing database only")
|
||||
# Fallback to database-only testing
|
||||
import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True)
|
||||
return {
|
||||
"error": "Movie processor not available - only database test performed",
|
||||
"imdb_id": imdb_id,
|
||||
"radarr_configured": True,
|
||||
"movie_found": True,
|
||||
"movie_title": movie_title,
|
||||
"movie_id": movie_id,
|
||||
"detected_import_date": import_date,
|
||||
"import_source": source,
|
||||
"debug_info": {
|
||||
"note": "FULL PIPELINE TEST FAILED - movie processor not initialized"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as pipeline_error:
|
||||
print(f"ERROR: Full pipeline test failed: {pipeline_error}")
|
||||
# Fallback to database-only testing
|
||||
import_date, source = radarr_client.get_movie_import_date(movie_id, fallback_to_file_date=True)
|
||||
return {
|
||||
"pipeline_error": str(pipeline_error),
|
||||
"imdb_id": imdb_id,
|
||||
"radarr_configured": True,
|
||||
"movie_found": True,
|
||||
"movie_title": movie_title,
|
||||
"movie_id": movie_id,
|
||||
"detected_import_date": import_date,
|
||||
"import_source": source,
|
||||
"debug_info": {
|
||||
"note": "FULL PIPELINE TEST FAILED - showing database-only result"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Debug endpoint error for {imdb_id}: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"imdb_id": imdb_id,
|
||||
"success": False
|
||||
}
|
||||
|
||||
|
||||
async def debug_movie_history(imdb_id: str, dependencies: dict):
|
||||
"""Detailed history analysis for a movie"""
|
||||
movie_processor = dependencies["movie_processor"]
|
||||
|
||||
try:
|
||||
if not imdb_id.startswith("tt"):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
print(f"INFO: === DETAILED HISTORY ANALYSIS: {imdb_id} ===")
|
||||
|
||||
# This would need the rest of the implementation from the original function
|
||||
# For now, returning a placeholder
|
||||
return {
|
||||
"imdb_id": imdb_id,
|
||||
"message": "History analysis endpoint - implementation needed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Debug history endpoint error for {imdb_id}: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"imdb_id": imdb_id,
|
||||
"success": False
|
||||
}
|
||||
|
||||
|
||||
async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", dependencies: dict = None):
|
||||
"""Manual scan endpoint"""
|
||||
config = dependencies["config"]
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
tv_processor = dependencies["tv_processor"]
|
||||
movie_processor = dependencies["movie_processor"]
|
||||
|
||||
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 or path):
|
||||
# Handle specific season/episode path
|
||||
if path and scan_path.name.lower().startswith('season'):
|
||||
# Single season processing
|
||||
series_path = scan_path.parent
|
||||
if nfo_manager.parse_imdb_from_path(series_path):
|
||||
print(f"INFO: Processing single season: {scan_path}")
|
||||
try:
|
||||
tv_processor.process_season(series_path, scan_path)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing season {scan_path}: {e}")
|
||||
elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'):
|
||||
# Single episode processing
|
||||
season_path = scan_path.parent
|
||||
series_path = season_path.parent
|
||||
if nfo_manager.parse_imdb_from_path(series_path):
|
||||
print(f"INFO: Processing single episode: {scan_path}")
|
||||
try:
|
||||
tv_processor.process_episode_file(series_path, season_path, scan_path)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing episode {scan_path}: {e}")
|
||||
else:
|
||||
# Check if this path itself is a series (has IMDb ID in the directory name)
|
||||
if nfo_manager.parse_imdb_from_path(scan_path):
|
||||
try:
|
||||
tv_processor.process_series(scan_path)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing TV series {scan_path}: {e}")
|
||||
else:
|
||||
# Full series processing - scan subdirectories
|
||||
import re
|
||||
for item in scan_path.iterdir():
|
||||
if (item.is_dir() and
|
||||
not item.name.lower().startswith('season') and
|
||||
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
|
||||
nfo_manager.parse_imdb_from_path(item)):
|
||||
try:
|
||||
tv_processor.process_series(item)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing TV series {item}: {e}")
|
||||
|
||||
if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
|
||||
print(f"INFO: Scanning movies in: {scan_path}")
|
||||
movie_count = 0
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
|
||||
movie_count += 1
|
||||
print(f"INFO: Processing movie: {item.name}")
|
||||
try:
|
||||
movie_processor.process_movie(item)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing movie {item}: {e}")
|
||||
print(f"INFO: Completed movie scan: {movie_count} movies processed in {scan_path}")
|
||||
|
||||
background_tasks.add_task(run_scan)
|
||||
return {"status": "started", "message": f"Manual {scan_type} scan started"}
|
||||
|
||||
|
||||
async def scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest, dependencies: dict):
|
||||
"""Scan a specific TV season - URL-safe endpoint"""
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
tv_processor = dependencies["tv_processor"]
|
||||
|
||||
try:
|
||||
series_dir = Path(request.series_path)
|
||||
season_dir = series_dir / request.season_name
|
||||
|
||||
if not series_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}")
|
||||
if not season_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Season path not found: {season_dir}")
|
||||
|
||||
imdb_id = nfo_manager.parse_imdb_from_path(series_dir)
|
||||
if not imdb_id:
|
||||
raise HTTPException(status_code=400, detail="No IMDb ID found in series path")
|
||||
|
||||
async def process_season():
|
||||
print(f"INFO: Processing TV season: {season_dir}")
|
||||
try:
|
||||
tv_processor.process_season(series_dir, season_dir)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing season {season_dir}: {e}")
|
||||
|
||||
background_tasks.add_task(process_season)
|
||||
return {"status": "started", "message": f"Season scan started for {request.season_name}"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def scan_tv_episode(background_tasks: BackgroundTasks, request: TVEpisodeRequest, dependencies: dict):
|
||||
"""Scan a specific TV episode - URL-safe endpoint"""
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
tv_processor = dependencies["tv_processor"]
|
||||
|
||||
try:
|
||||
series_dir = Path(request.series_path)
|
||||
season_dir = series_dir / request.season_name
|
||||
episode_file = season_dir / request.episode_name
|
||||
|
||||
if not series_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Series path not found: {request.series_path}")
|
||||
if not episode_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Episode file not found: {episode_file}")
|
||||
|
||||
imdb_id = nfo_manager.parse_imdb_from_path(series_dir)
|
||||
if not imdb_id:
|
||||
raise HTTPException(status_code=400, detail="No IMDb ID found in series path")
|
||||
|
||||
async def process_episode():
|
||||
print(f"INFO: Processing TV episode: {episode_file}")
|
||||
try:
|
||||
tv_processor.process_episode_file(series_dir, season_dir, episode_file)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing episode {episode_file}: {e}")
|
||||
|
||||
background_tasks.add_task(process_episode)
|
||||
return {"status": "started", "message": f"Episode scan started for {request.episode_name}"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def test_bulk_update(dependencies: dict):
|
||||
"""Test bulk update functionality without modifying data"""
|
||||
try:
|
||||
from clients.radarr_db_client import RadarrDbClient
|
||||
|
||||
# Test Radarr database
|
||||
radarr_db = RadarrDbClient.from_env()
|
||||
if not radarr_db:
|
||||
return {"status": "error", "message": "Radarr database connection failed"}
|
||||
|
||||
# Test query execution
|
||||
query = 'SELECT COUNT(*) FROM "Movies" m JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" WHERE mm."ImdbId" IS NOT NULL'
|
||||
with radarr_db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query)
|
||||
movie_count = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Bulk update test passed",
|
||||
"movies_with_imdb": movie_count,
|
||||
"database_type": radarr_db.db_type
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"Bulk update test failed: {e}"}
|
||||
|
||||
|
||||
async def test_movie_scan(dependencies: dict):
|
||||
"""Test movie directory scanning logic"""
|
||||
config = dependencies["config"]
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
|
||||
try:
|
||||
results = []
|
||||
for path in config.movie_paths:
|
||||
path_result = {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"movies_found": 0
|
||||
}
|
||||
|
||||
if path.exists():
|
||||
for item in path.iterdir():
|
||||
if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
|
||||
path_result["movies_found"] += 1
|
||||
|
||||
results.append(path_result)
|
||||
|
||||
total_movies = sum(r["movies_found"] for r in results)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Movie scan test found {total_movies} movies",
|
||||
"path_results": results
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": f"Movie scan test failed: {e}"}
|
||||
|
||||
|
||||
async def trigger_bulk_update(background_tasks: BackgroundTasks, dependencies: dict):
|
||||
"""Trigger bulk update of all movies"""
|
||||
async def run_bulk_update():
|
||||
try:
|
||||
from bulk_update_movies import bulk_update_all_movies
|
||||
success = bulk_update_all_movies()
|
||||
print(f"INFO: Bulk update completed: {'success' if success else 'failed'}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Bulk update error: {e}")
|
||||
|
||||
background_tasks.add_task(run_bulk_update)
|
||||
return {"status": "started", "message": "Bulk update started"}
|
||||
|
||||
|
||||
async def debug_movie_priority_logic(imdb_id: str, dependencies: dict):
|
||||
"""Debug endpoint showing how MOVIE_PRIORITY affects date selection"""
|
||||
config = dependencies["config"]
|
||||
movie_processor = dependencies["movie_processor"]
|
||||
|
||||
try:
|
||||
if not imdb_id.startswith("tt"):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
result = {
|
||||
"imdb_id": imdb_id,
|
||||
"movie_priority": config.movie_priority,
|
||||
"release_date_priority": config.release_date_priority,
|
||||
"priority_explanation": "",
|
||||
"date_sources": {},
|
||||
"selected_date": None,
|
||||
"selected_source": None
|
||||
}
|
||||
|
||||
# Get Radarr import date
|
||||
if movie_processor.radarr.api_key:
|
||||
radarr_movie = movie_processor.radarr.movie_by_imdb(imdb_id)
|
||||
if radarr_movie:
|
||||
movie_id = radarr_movie.get("id")
|
||||
if movie_id:
|
||||
import_date, import_source = movie_processor.radarr.get_movie_import_date(movie_id)
|
||||
if import_date:
|
||||
result["date_sources"]["radarr_import"] = {
|
||||
"date": import_date,
|
||||
"source": import_source
|
||||
}
|
||||
|
||||
# Get digital release dates with detailed logging
|
||||
digital_date, digital_source = movie_processor._get_digital_release_date(imdb_id)
|
||||
if digital_date:
|
||||
result["date_sources"]["digital_release"] = {
|
||||
"date": digital_date,
|
||||
"source": digital_source
|
||||
}
|
||||
else:
|
||||
# Add debug info about why digital date wasn't found
|
||||
candidates = movie_processor.external_clients.get_digital_release_candidates(imdb_id)
|
||||
result["date_sources"]["digital_release_debug"] = {
|
||||
"candidates_found": len(candidates),
|
||||
"candidates": candidates[:3] if candidates else [], # Show first 3
|
||||
"reason": digital_source if digital_source else "no_digital_dates_found"
|
||||
}
|
||||
|
||||
# Show priority logic
|
||||
if config.movie_priority == "import_then_digital":
|
||||
priority_list = " → ".join(config.release_date_priority)
|
||||
result["priority_explanation"] = f"1st: Radarr import history, 2nd: Release dates ({priority_list}), 3rd: file mtime. Note: If import is only file date, prefer reasonable release dates."
|
||||
|
||||
radarr_import = result["date_sources"].get("radarr_import")
|
||||
digital_release = result["date_sources"].get("digital_release")
|
||||
|
||||
# Check for file date fallback logic
|
||||
if radarr_import and radarr_import["source"] == "radarr:db.file.dateAdded" and digital_release:
|
||||
# Test the smart logic
|
||||
would_prefer_digital = movie_processor._should_prefer_release_over_file_date(
|
||||
digital_release["date"],
|
||||
digital_release["source"],
|
||||
None, # We don't have theatrical date in this debug context
|
||||
imdb_id
|
||||
)
|
||||
result["file_date_detected"] = True
|
||||
result["would_prefer_digital"] = would_prefer_digital
|
||||
|
||||
if would_prefer_digital:
|
||||
result["selected_date"] = digital_release["date"]
|
||||
result["selected_source"] = digital_release["source"] + " (preferred over file date)"
|
||||
else:
|
||||
result["selected_date"] = radarr_import["date"]
|
||||
result["selected_source"] = radarr_import["source"] + " (digital too old)"
|
||||
elif radarr_import and radarr_import["source"] != "radarr:db.file.dateAdded":
|
||||
result["selected_date"] = radarr_import["date"]
|
||||
result["selected_source"] = radarr_import["source"]
|
||||
elif digital_release:
|
||||
result["selected_date"] = digital_release["date"]
|
||||
result["selected_source"] = digital_release["source"]
|
||||
else: # digital_then_import
|
||||
result["priority_explanation"] = "1st: TMDB/OMDb digital release, 2nd: Radarr import history, 3rd: file mtime"
|
||||
if result["date_sources"].get("digital_release"):
|
||||
result["selected_date"] = result["date_sources"]["digital_release"]["date"]
|
||||
result["selected_source"] = result["date_sources"]["digital_release"]["source"]
|
||||
elif result["date_sources"].get("radarr_import"):
|
||||
result["selected_date"] = result["date_sources"]["radarr_import"]["date"]
|
||||
result["selected_source"] = result["date_sources"]["radarr_import"]["source"]
|
||||
|
||||
# Show external API status
|
||||
result["external_apis"] = {
|
||||
"tmdb_enabled": movie_processor.external_clients.tmdb.enabled,
|
||||
"omdb_enabled": movie_processor.external_clients.omdb.enabled,
|
||||
"jellyseerr_enabled": movie_processor.external_clients.jellyseerr.enabled
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e), "imdb_id": imdb_id}
|
||||
|
||||
|
||||
async def debug_tmdb_lookup(imdb_id: str, dependencies: dict):
|
||||
"""Debug TMDB API lookup for a specific movie"""
|
||||
movie_processor = dependencies["movie_processor"]
|
||||
|
||||
try:
|
||||
if not imdb_id.startswith("tt"):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
result = {
|
||||
"imdb_id": imdb_id,
|
||||
"tmdb_api_enabled": movie_processor.external_clients.tmdb.enabled,
|
||||
"tmdb_api_key_configured": bool(movie_processor.external_clients.tmdb.api_key),
|
||||
"steps": {}
|
||||
}
|
||||
|
||||
if not movie_processor.external_clients.tmdb.enabled:
|
||||
result["error"] = "TMDB API not enabled - check TMDB_API_KEY environment variable"
|
||||
return result
|
||||
|
||||
# Step 1: Find movie by IMDb ID
|
||||
print(f"INFO: TMDB Debug: Looking up {imdb_id}")
|
||||
tmdb_movie = movie_processor.external_clients.tmdb.find_by_imdb(imdb_id)
|
||||
result["steps"]["1_find_by_imdb"] = {
|
||||
"found": bool(tmdb_movie),
|
||||
"tmdb_movie": tmdb_movie if tmdb_movie else None
|
||||
}
|
||||
|
||||
if not tmdb_movie:
|
||||
result["error"] = f"Movie {imdb_id} not found in TMDB"
|
||||
return result
|
||||
|
||||
tmdb_id = tmdb_movie.get("id")
|
||||
result["tmdb_id"] = tmdb_id
|
||||
|
||||
# Step 2: Get release dates
|
||||
if tmdb_id:
|
||||
print(f"INFO: TMDB Debug: Getting release dates for TMDB ID {tmdb_id}")
|
||||
release_dates_result = movie_processor.external_clients.tmdb._get(f"/movie/{tmdb_id}/release_dates")
|
||||
result["steps"]["2_release_dates"] = {
|
||||
"raw_response": release_dates_result,
|
||||
"has_results": bool(release_dates_result and release_dates_result.get("results"))
|
||||
}
|
||||
|
||||
# Step 3: Look for US digital releases
|
||||
if release_dates_result and release_dates_result.get("results"):
|
||||
us_releases = []
|
||||
for country_data in release_dates_result["results"]:
|
||||
if country_data.get("iso_3166_1") == "US":
|
||||
us_releases = country_data.get("release_dates", [])
|
||||
break
|
||||
|
||||
result["steps"]["3_us_releases"] = {
|
||||
"found_us_data": bool(us_releases),
|
||||
"us_releases": us_releases
|
||||
}
|
||||
|
||||
# Step 4: Look for digital releases (type 4)
|
||||
digital_releases = [r for r in us_releases if r.get("type") == 4]
|
||||
result["steps"]["4_digital_releases"] = {
|
||||
"digital_count": len(digital_releases),
|
||||
"digital_releases": digital_releases
|
||||
}
|
||||
|
||||
# Step 5: Test the full digital release function
|
||||
digital_date = movie_processor.external_clients.tmdb.get_digital_release_date(imdb_id)
|
||||
result["steps"]["5_final_result"] = {
|
||||
"digital_date": digital_date,
|
||||
"success": bool(digital_date)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e), "imdb_id": imdb_id, "traceback": str(e)}
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Route Registration
|
||||
# ---------------------------
|
||||
|
||||
def register_routes(app, dependencies: dict):
|
||||
"""
|
||||
Register all routes with the FastAPI app
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
dependencies: Dictionary containing:
|
||||
- db: NFOGuardDatabase instance
|
||||
- nfo_manager: NFOManager instance
|
||||
- path_mapper: PathMapper instance
|
||||
- tv_processor: TVProcessor instance
|
||||
- movie_processor: MovieProcessor instance
|
||||
- batcher: WebhookBatcher instance
|
||||
- start_time: Application start time
|
||||
- config: NFOGuardConfig instance
|
||||
- version: Application version string
|
||||
"""
|
||||
|
||||
@app.post("/webhook/sonarr")
|
||||
async def _sonarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
return await sonarr_webhook(request, background_tasks, dependencies)
|
||||
|
||||
@app.post("/webhook/radarr")
|
||||
async def _radarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
||||
return await radarr_webhook(request, background_tasks, dependencies)
|
||||
|
||||
@app.get("/health")
|
||||
async def _health() -> HealthResponse:
|
||||
return await health(dependencies)
|
||||
|
||||
@app.get("/stats")
|
||||
async def _get_stats():
|
||||
return await get_stats(dependencies)
|
||||
|
||||
@app.get("/batch/status")
|
||||
async def _batch_status():
|
||||
return await batch_status(dependencies)
|
||||
|
||||
@app.get("/debug/movie/{imdb_id}")
|
||||
async def _debug_movie_import_date(imdb_id: str):
|
||||
return await debug_movie_import_date(imdb_id, dependencies)
|
||||
|
||||
@app.get("/debug/movie/{imdb_id}/history")
|
||||
async def _debug_movie_history(imdb_id: str):
|
||||
return await debug_movie_history(imdb_id, dependencies)
|
||||
|
||||
@app.post("/manual/scan")
|
||||
async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both"):
|
||||
return await manual_scan(background_tasks, path, scan_type, dependencies)
|
||||
|
||||
@app.post("/tv/scan-season")
|
||||
async def _scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest):
|
||||
return await scan_tv_season(background_tasks, request, dependencies)
|
||||
|
||||
@app.post("/tv/scan-episode")
|
||||
async def _scan_tv_episode(background_tasks: BackgroundTasks, request: TVEpisodeRequest):
|
||||
return await scan_tv_episode(background_tasks, request, dependencies)
|
||||
|
||||
@app.post("/test/bulk-update")
|
||||
async def _test_bulk_update():
|
||||
return await test_bulk_update(dependencies)
|
||||
|
||||
@app.post("/test/movie-scan")
|
||||
async def _test_movie_scan():
|
||||
return await test_movie_scan(dependencies)
|
||||
|
||||
@app.post("/bulk/update")
|
||||
async def _trigger_bulk_update(background_tasks: BackgroundTasks):
|
||||
return await trigger_bulk_update(background_tasks, dependencies)
|
||||
|
||||
@app.get("/debug/movie/{imdb_id}/priority")
|
||||
async def _debug_movie_priority_logic(imdb_id: str):
|
||||
return await debug_movie_priority_logic(imdb_id, dependencies)
|
||||
|
||||
@app.get("/debug/tmdb/{imdb_id}")
|
||||
async def _debug_tmdb_lookup(imdb_id: str):
|
||||
return await debug_tmdb_lookup(imdb_id, dependencies)
|
||||
Reference in New Issue
Block a user