3174 lines
149 KiB
Python
3174 lines
149 KiB
Python
"""
|
|
FastAPI routes for NFOGuard - extracted from main nfoguard.py for modular architecture
|
|
"""
|
|
import os
|
|
import json
|
|
import requests
|
|
import asyncio
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
from fastapi import HTTPException, BackgroundTasks, Request, Response
|
|
from typing import Optional
|
|
|
|
# Import models
|
|
from api.models import (
|
|
SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
|
|
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
|
|
)
|
|
# Web routes removed - handled by separate web container
|
|
|
|
# Global scan status tracking for detailed progress
|
|
scan_status = {
|
|
"scanning": False,
|
|
"scan_type": None,
|
|
"scan_mode": None,
|
|
"start_time": None,
|
|
"current_operation": None,
|
|
"tv_series_processed": 0,
|
|
"tv_series_total": 0,
|
|
"tv_series_skipped": 0,
|
|
"movies_processed": 0,
|
|
"movies_total": 0,
|
|
"movies_skipped": 0,
|
|
"current_item": None,
|
|
"last_update": None
|
|
}
|
|
|
|
|
|
# ---------------------------
|
|
# 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"}
|
|
|
|
# Extract episode data for targeted processing
|
|
episodes_data = webhook.episodes or []
|
|
print(f"DEBUG: Initial episodes_data from webhook.episodes: {len(episodes_data)} episodes")
|
|
|
|
# For all webhook events, if no episodes in webhook.episodes, try to extract from episodeFile
|
|
# This ensures targeted processing for single episode operations (Download, Rename, Upgrade)
|
|
print(f"DEBUG: webhook.episodeFile present: {webhook.episodeFile is not None}")
|
|
if webhook.episodeFile:
|
|
print(f"DEBUG: episodeFile content: {webhook.episodeFile}")
|
|
|
|
if not episodes_data and webhook.episodeFile:
|
|
episode_file = webhook.episodeFile
|
|
# Extract season and episode from episodeFile path/filename
|
|
season_num = episode_file.get("seasonNumber")
|
|
episode_num = episode_file.get("episodeNumber")
|
|
|
|
# If not directly available, parse from relativePath or path
|
|
if not (season_num and episode_num):
|
|
from utils.nfo_patterns import extract_episode_info_from_filename
|
|
|
|
# Try relativePath first, then path
|
|
file_path = episode_file.get("relativePath") or episode_file.get("path", "")
|
|
print(f"DEBUG: Parsing episode info from path: {file_path}")
|
|
|
|
episode_info = extract_episode_info_from_filename(file_path)
|
|
if episode_info:
|
|
season_num = episode_info["season"]
|
|
episode_num = episode_info["episode"]
|
|
print(f"DEBUG: Extracted from filename - Season: {season_num}, Episode: {episode_num}")
|
|
else:
|
|
print(f"DEBUG: Could not extract season/episode from filename: {file_path}")
|
|
|
|
print(f"DEBUG: episodeFile seasonNumber: {season_num}, episodeNumber: {episode_num}")
|
|
if season_num and episode_num:
|
|
# Create episode data structure that matches what process_webhook_episodes expects
|
|
episodes_data = [{
|
|
"seasonNumber": season_num,
|
|
"episodeNumber": episode_num,
|
|
"id": episode_file.get("id"),
|
|
"title": episode_file.get("title")
|
|
# Note: Not including dateAdded - we use database-first approach with Sonarr fallback
|
|
}]
|
|
print(f"INFO: Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}")
|
|
else:
|
|
print(f"DEBUG: Missing season/episode numbers in episodeFile for {webhook.eventType}")
|
|
|
|
# Special handling for Rename events - Sonarr doesn't include episodeFile for renames
|
|
# Try to find recently renamed episodes using Sonarr history API
|
|
if not episodes_data and webhook.eventType == "Rename":
|
|
print(f"DEBUG: Attempting to find recently renamed episode for series {imdb_id}")
|
|
try:
|
|
# Get series info from Sonarr to find series ID
|
|
series_lookup_url = f"{config.sonarr_url}/api/v3/series/lookup?term=imdbid:{imdb_id}"
|
|
print(f"DEBUG: Sonarr lookup for rename: {series_lookup_url}")
|
|
|
|
response = requests.get(series_lookup_url, headers={"X-Api-Key": os.environ.get("SONARR_API_KEY", "")}, timeout=10)
|
|
if response.status_code == 200:
|
|
series_results = response.json()
|
|
if series_results:
|
|
series_id = series_results[0].get("id")
|
|
print(f"DEBUG: Found series ID {series_id} for rename lookup")
|
|
|
|
# Get recent history for the series and filter for rename events
|
|
from datetime import datetime, timedelta
|
|
since_date = (datetime.utcnow() - timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
history_url = f"{config.sonarr_url}/api/v3/history?seriesId={series_id}&sortKey=date&sortDir=desc&page=1&pageSize=50"
|
|
print(f"DEBUG: Checking recent rename history: {history_url}")
|
|
|
|
history_response = requests.get(history_url, headers={"X-Api-Key": os.environ.get("SONARR_API_KEY", "")}, timeout=10)
|
|
if history_response.status_code == 200:
|
|
history_data = history_response.json()
|
|
all_records = history_data.get("records", [])
|
|
print(f"DEBUG: Got {len(all_records)} total history records")
|
|
|
|
# Filter for recent rename events
|
|
since_timestamp = datetime.utcnow() - timedelta(hours=1)
|
|
recent_renames = []
|
|
|
|
for record in all_records:
|
|
event_type = record.get("eventType", "")
|
|
date_str = record.get("date", "")
|
|
|
|
if event_type == "episodeFileRenamed" and date_str:
|
|
try:
|
|
event_time = datetime.strptime(date_str.replace('Z', '+00:00'), '%Y-%m-%dT%H:%M:%S.%f%z')
|
|
event_time_utc = event_time.utctimetuple()
|
|
if datetime(*event_time_utc[:6]) > since_timestamp:
|
|
recent_renames.append(record)
|
|
except:
|
|
# If datetime parsing fails, include it anyway
|
|
recent_renames.append(record)
|
|
|
|
print(f"DEBUG: Found {len(recent_renames)} recent rename events")
|
|
|
|
if recent_renames:
|
|
# Take the most recent rename event
|
|
latest_rename = recent_renames[0]
|
|
print(f"DEBUG: Processing latest rename event")
|
|
|
|
# Extract episodeId directly from the rename event
|
|
episode_id = latest_rename.get("episodeId")
|
|
print(f"DEBUG: Found episodeId {episode_id} in rename event")
|
|
|
|
if episode_id:
|
|
# Fetch episode details using the episodeId
|
|
episode_detail_url = f"{config.sonarr_url}/api/v3/episode/{episode_id}"
|
|
episode_response = requests.get(episode_detail_url, headers={"X-Api-Key": os.environ.get("SONARR_API_KEY", "")}, timeout=10)
|
|
|
|
if episode_response.status_code == 200:
|
|
episode_detail = episode_response.json()
|
|
season_num = episode_detail.get("seasonNumber")
|
|
episode_num = episode_detail.get("episodeNumber")
|
|
episode_title = episode_detail.get("title")
|
|
|
|
print(f"DEBUG: Episode details - Season: {season_num}, Episode: {episode_num}, Title: {episode_title}")
|
|
|
|
if season_num is not None and episode_num is not None:
|
|
episodes_data = [{
|
|
"seasonNumber": season_num,
|
|
"episodeNumber": episode_num,
|
|
"id": episode_id,
|
|
"title": episode_title
|
|
}]
|
|
print(f"INFO: Successfully identified renamed episode: S{season_num:02d}E{episode_num:02d} - {episode_title}")
|
|
else:
|
|
print(f"DEBUG: Episode details missing season/episode numbers")
|
|
else:
|
|
print(f"DEBUG: Failed to fetch episode details: {episode_response.status_code}")
|
|
else:
|
|
print(f"DEBUG: No episodeId found in rename event")
|
|
else:
|
|
print(f"DEBUG: No recent rename events found in last hour")
|
|
else:
|
|
print(f"DEBUG: Failed to get rename history: {history_response.status_code}")
|
|
else:
|
|
print(f"DEBUG: No series found for IMDb {imdb_id}")
|
|
else:
|
|
print(f"DEBUG: Series lookup failed: {response.status_code}")
|
|
except Exception as e:
|
|
print(f"DEBUG: Error finding renamed episode: {e}")
|
|
# Continue with series processing as fallback
|
|
|
|
# Force targeted mode for single-episode webhooks to prevent full series processing
|
|
processing_mode = config.tv_webhook_processing_mode
|
|
if episodes_data and len(episodes_data) <= 3: # Single episode or small batch
|
|
processing_mode = "targeted"
|
|
print(f"INFO: Forcing targeted mode for {len(episodes_data)} episode(s)")
|
|
|
|
# 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': episodes_data, # Include enhanced episode data for targeted processing
|
|
'processing_mode': processing_mode # Use forced targeted mode when appropriate
|
|
}
|
|
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 maintainarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict):
|
|
"""Handle Maintainarr webhooks for media deletion"""
|
|
db = dependencies["db"]
|
|
config = dependencies["config"]
|
|
|
|
try:
|
|
payload = await _read_payload(request)
|
|
if not payload:
|
|
raise HTTPException(status_code=422, detail="Empty Maintainarr payload")
|
|
|
|
webhook = MaintainarrWebhook(**payload)
|
|
print(f"INFO: Received Maintainarr webhook: {webhook.notification_type}")
|
|
print(f"DEBUG: Full Maintainarr webhook payload: {payload}")
|
|
|
|
# Handle test notifications differently for debugging
|
|
notification_type = webhook.notification_type or ""
|
|
if notification_type == "TEST_NOTIFICATION":
|
|
return {
|
|
"status": "test_received",
|
|
"message": "Test notification received successfully",
|
|
"available_fields": {
|
|
"notification_type": webhook.notification_type,
|
|
"subject": webhook.subject,
|
|
"message": webhook.message,
|
|
"extra": webhook.extra
|
|
},
|
|
"debug": "This is a test notification. Real media removal events will be processed when they occur."
|
|
}
|
|
|
|
# Only process media removal notifications
|
|
if "removed" not in notification_type.lower() and "delete" not in notification_type.lower():
|
|
return {"status": "ignored", "reason": f"Notification type '{notification_type}' not processed"}
|
|
|
|
# Parse message to extract media information
|
|
message = webhook.message or ""
|
|
subject = webhook.subject or ""
|
|
extra = webhook.extra or ""
|
|
|
|
# Try to extract IMDb ID from message, subject, or extra data
|
|
import re
|
|
imdb_pattern = r'tt\d{7,8}|\b\d{7,8}\b'
|
|
|
|
imdb_id = None
|
|
title = "Unknown Media"
|
|
media_type = "Unknown"
|
|
|
|
# Look for IMDb ID in all fields
|
|
for field in [message, subject, extra]:
|
|
if field:
|
|
imdb_matches = re.findall(imdb_pattern, field)
|
|
if imdb_matches:
|
|
imdb_id = imdb_matches[0]
|
|
if not imdb_id.startswith("tt"):
|
|
imdb_id = f"tt{imdb_id}"
|
|
break
|
|
|
|
if not imdb_id:
|
|
print(f"WARNING: No IMDb ID found in Maintainarr webhook - Message: '{message}', Subject: '{subject}'")
|
|
return {"status": "ignored", "reason": "No IMDb ID found in webhook payload"}
|
|
|
|
# Try to extract title from subject or message
|
|
if subject and subject.strip():
|
|
title = subject.strip()
|
|
elif message and message.strip():
|
|
# Extract title from message if possible
|
|
title_match = re.search(r'(?:movie|series|show)\s*[:\-]?\s*(.+?)(?:\s*\(|$)', message, re.IGNORECASE)
|
|
if title_match:
|
|
title = title_match.group(1).strip()
|
|
else:
|
|
title = message.strip()
|
|
|
|
# Try to determine media type from message content
|
|
if any(word in message.lower() for word in ["series", "show", "tv", "season", "episode"]):
|
|
media_type = "Series"
|
|
elif any(word in message.lower() for word in ["movie", "film"]):
|
|
media_type = "Movie"
|
|
else:
|
|
# Try both types - first check if it's a series, then movie
|
|
series = db.get_series_by_imdb(imdb_id)
|
|
movie = db.get_movie_by_imdb(imdb_id)
|
|
|
|
if series:
|
|
media_type = "Series"
|
|
elif movie:
|
|
media_type = "Movie"
|
|
else:
|
|
print(f"INFO: Media {title} ({imdb_id}) not found in database")
|
|
return {"status": "ignored", "reason": f"Media {imdb_id} not found in database"}
|
|
|
|
# Process deletion based on media type
|
|
removed_count = 0
|
|
removed_items = []
|
|
|
|
if media_type == "Movie":
|
|
print(f"INFO: Processing movie deletion for {title} ({imdb_id})")
|
|
|
|
# Check if movie exists in database
|
|
movie = db.get_movie_by_imdb(imdb_id)
|
|
if movie:
|
|
if db.delete_movie(imdb_id):
|
|
removed_count += 1
|
|
removed_items.append(f"Movie: {title} ({imdb_id})")
|
|
print(f"SUCCESS: Removed movie {title} ({imdb_id}) from database")
|
|
else:
|
|
print(f"WARNING: Failed to remove movie {title} ({imdb_id}) from database")
|
|
else:
|
|
print(f"INFO: Movie {title} ({imdb_id}) not found in database")
|
|
|
|
elif media_type == "Series":
|
|
print(f"INFO: Processing series deletion for {title} ({imdb_id})")
|
|
|
|
# Check if series exists in database
|
|
series = db.get_series_by_imdb(imdb_id)
|
|
if series:
|
|
# Delete all episodes for the series
|
|
episodes_removed = db.delete_series_episodes(imdb_id)
|
|
if episodes_removed > 0:
|
|
removed_count += episodes_removed
|
|
removed_items.append(f"Series episodes: {title} ({imdb_id}) - {episodes_removed} episodes")
|
|
print(f"SUCCESS: Removed {episodes_removed} episodes for series {title} ({imdb_id})")
|
|
|
|
# Delete the series record
|
|
if db.delete_series(imdb_id):
|
|
removed_count += 1
|
|
removed_items.append(f"Series: {title} ({imdb_id})")
|
|
print(f"SUCCESS: Removed series {title} ({imdb_id}) from database")
|
|
else:
|
|
print(f"WARNING: Failed to remove series {title} ({imdb_id}) from database")
|
|
else:
|
|
print(f"INFO: Series {title} ({imdb_id}) not found in database")
|
|
|
|
# Log the cleanup operation
|
|
if removed_count > 0:
|
|
background_tasks.add_task(
|
|
_log_maintainarr_cleanup,
|
|
notification_type,
|
|
media_type,
|
|
title,
|
|
imdb_id,
|
|
removed_items,
|
|
webhook.subject
|
|
)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"Processed {notification_type} for {title}",
|
|
"media_type": media_type,
|
|
"imdb_id": imdb_id,
|
|
"removed_count": removed_count,
|
|
"removed_items": removed_items
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"ERROR: Maintainarr webhook error: {e}")
|
|
import traceback
|
|
print(f"ERROR: Traceback: {traceback.format_exc()}")
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
|
|
async def _log_maintainarr_cleanup(event_type: str, media_type: str, title: str, imdb_id: str, removed_items: list, collection_name: str = None):
|
|
"""Background task to log Maintainarr cleanup operations"""
|
|
try:
|
|
log_message = f"Maintainarr cleanup: {event_type} - {media_type} '{title}' ({imdb_id})"
|
|
if collection_name:
|
|
log_message += f" from collection '{collection_name}'"
|
|
log_message += f". Removed from database: {', '.join(removed_items)}"
|
|
|
|
print(f"INFO: {log_message}")
|
|
|
|
# Could extend this to write to a cleanup log file or database table
|
|
|
|
except Exception as e:
|
|
print(f"ERROR: Failed to log Maintainarr cleanup: {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", scan_mode: str = "smart", dependencies: dict = None):
|
|
"""Manual scan endpoint with smart optimization modes"""
|
|
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'")
|
|
|
|
if scan_mode not in ["smart", "full", "incomplete"]:
|
|
raise HTTPException(status_code=400, detail="scan_mode must be 'smart', 'full', or 'incomplete'")
|
|
|
|
async def run_scan():
|
|
from datetime import datetime, timezone
|
|
import time
|
|
import os
|
|
start_time = datetime.now()
|
|
|
|
# Handle timezone display - check if TZ is set in container
|
|
try:
|
|
tz_name = os.environ.get('TZ')
|
|
if tz_name:
|
|
# TZ is set, so datetime.now() already returns local time
|
|
local_start = start_time
|
|
tz_display = f" ({tz_name})"
|
|
else:
|
|
# No TZ set, assume container is UTC and convert to Eastern
|
|
import zoneinfo
|
|
local_tz = zoneinfo.ZoneInfo("America/New_York")
|
|
local_start = start_time.replace(tzinfo=timezone.utc).astimezone(local_tz)
|
|
tz_display = " (EDT/EST)"
|
|
except:
|
|
# Ultimate fallback - just show time as-is with note
|
|
local_start = start_time
|
|
tz_display = " (container time)"
|
|
|
|
print(f"🚀 MANUAL SCAN STARTED: {scan_type} scan (mode: {scan_mode}) initiated at {local_start.strftime('%Y-%m-%d %H:%M:%S')}{tz_display}")
|
|
|
|
# Initialize scan tracking
|
|
start_scan_tracking(scan_type, scan_mode)
|
|
|
|
# Initialize counters for scan statistics
|
|
tv_series_total = 0
|
|
tv_series_skipped = 0
|
|
tv_series_processed = 0
|
|
movie_total = 0
|
|
movie_skipped = 0
|
|
movie_processed = 0
|
|
|
|
paths_to_scan = []
|
|
if path:
|
|
paths_to_scan = [Path(path)]
|
|
print(f"DEBUG: Manual scan with specific 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:
|
|
print(f"DEBUG: Checking scan_path: {scan_path}, exists: {scan_path.exists()}")
|
|
if not scan_path.exists():
|
|
print(f"DEBUG: Path does not exist, skipping: {scan_path}")
|
|
continue
|
|
|
|
print(f"DEBUG: scan_type={scan_type}, path={path}, scan_path in tv_paths: {scan_path in config.tv_paths}")
|
|
if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path):
|
|
# Handle specific season/episode path
|
|
print(f"DEBUG: Entered TV processing branch")
|
|
if path and scan_path.name.lower().startswith('season'):
|
|
print(f"DEBUG: Taking season processing path")
|
|
# Single season processing
|
|
series_path = scan_path.parent
|
|
tv_processor_obj = dependencies.get("tv_processor")
|
|
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client, shutdown_event):
|
|
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'):
|
|
print(f"DEBUG: Taking single episode processing path")
|
|
# Single episode processing
|
|
season_path = scan_path.parent
|
|
series_path = season_path.parent
|
|
tv_processor_obj = dependencies.get("tv_processor")
|
|
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client, shutdown_event):
|
|
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:
|
|
print(f"DEBUG: Taking series processing path")
|
|
# Check if this path itself is a series (has IMDb ID in directory name or NFO files)
|
|
tv_processor_obj = dependencies.get("tv_processor")
|
|
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client, shutdown_event)
|
|
print(f"DEBUG: Manual scan IMDb detection for {scan_path}: {imdb_id}")
|
|
if imdb_id:
|
|
try:
|
|
# Determine force_scan based on scan mode
|
|
force_scan = (scan_mode == "full")
|
|
print(f"DEBUG: Processing series {scan_path} with force_scan={force_scan}")
|
|
result = tv_processor.process_series(scan_path, force_scan=force_scan)
|
|
tv_series_total += 1
|
|
if result == "skipped":
|
|
tv_series_skipped += 1
|
|
elif result == "processed":
|
|
tv_series_processed += 1
|
|
except Exception as e:
|
|
print(f"ERROR: Failed processing TV series {scan_path}: {e}")
|
|
tv_series_total += 1
|
|
else:
|
|
# Full series processing - scan subdirectories
|
|
import re
|
|
|
|
# Count total series first for progress tracking
|
|
tv_series_list = []
|
|
for item in scan_path.iterdir():
|
|
# Check for shutdown signal during series discovery
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if shutdown_event and shutdown_event.is_set():
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping series discovery")
|
|
return
|
|
|
|
if (item.is_dir() and
|
|
not item.name.lower().startswith('season') and
|
|
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE)):
|
|
|
|
# Check for IMDb ID (enhanced with NFO fallback)
|
|
tv_processor_obj = dependencies.get("tv_processor")
|
|
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
|
imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(item, sonarr_client, shutdown_event)
|
|
if imdb_id:
|
|
tv_series_list.append(item)
|
|
else:
|
|
# Log missing IMDb ID for TV series
|
|
try:
|
|
db = dependencies.get("db")
|
|
db.add_missing_imdb(
|
|
file_path=str(item),
|
|
media_type="tv",
|
|
folder_name=item.name,
|
|
filename=None,
|
|
notes=f"TV series directory without IMDb ID detected during scan"
|
|
)
|
|
print(f"⚠️ Missing IMDb ID: TV series {item.name} - logged for manual review")
|
|
except Exception as e:
|
|
print(f"❌ Failed to log missing IMDb for TV series {item.name}: {e}")
|
|
|
|
tv_series_count = len(tv_series_list)
|
|
update_scan_status("tv", tv_series_total=tv_series_count)
|
|
print(f"INFO: Found {tv_series_count} TV series to process")
|
|
|
|
tv_count = 0
|
|
for item in tv_series_list:
|
|
# Check for shutdown signal at start of each item
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if shutdown_event and shutdown_event.is_set():
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
|
return
|
|
|
|
tv_count += 1
|
|
update_scan_status(current_item=item.name, tv_series_processed=tv_count)
|
|
|
|
try:
|
|
# Determine force_scan based on scan mode
|
|
force_scan = (scan_mode == "full")
|
|
result = tv_processor.process_series(item, force_scan=force_scan)
|
|
tv_series_total += 1
|
|
if result == "skipped":
|
|
tv_series_skipped += 1
|
|
elif result == "processed":
|
|
tv_series_processed += 1
|
|
except Exception as e:
|
|
print(f"ERROR: Failed processing TV series {item}: {e}")
|
|
tv_series_total += 1
|
|
|
|
# Yield control every TV series to allow other requests
|
|
if tv_count % 1 == 0:
|
|
await asyncio.sleep(0.2) # 200ms yield to process other requests
|
|
print(f"INFO: Processed {tv_count} TV series, yielding to other requests...")
|
|
|
|
# Check for shutdown signal
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if shutdown_event and shutdown_event.is_set():
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
|
return
|
|
|
|
if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
|
|
print(f"INFO: Scanning movies in: {scan_path}")
|
|
update_scan_status("movies", current_item="Counting movies...")
|
|
|
|
# Count total movies first for progress tracking
|
|
movie_list = []
|
|
for item in scan_path.iterdir():
|
|
# Check for shutdown signal during movie discovery
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if shutdown_event and shutdown_event.is_set():
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie discovery")
|
|
return
|
|
|
|
if item.is_dir():
|
|
# Check for IMDb ID
|
|
imdb_id = nfo_manager.find_movie_imdb_id(item)
|
|
if imdb_id:
|
|
movie_list.append(item)
|
|
else:
|
|
# Log missing IMDb ID for movie
|
|
try:
|
|
db = dependencies.get("db")
|
|
db.add_missing_imdb(
|
|
file_path=str(item),
|
|
media_type="movie",
|
|
folder_name=item.name,
|
|
filename=None,
|
|
notes=f"Movie directory without IMDb ID detected during scan"
|
|
)
|
|
print(f"⚠️ Missing IMDb ID: Movie {item.name} - logged for manual review")
|
|
except Exception as e:
|
|
print(f"❌ Failed to log missing IMDb for movie {item.name}: {e}")
|
|
|
|
movie_total_count = len(movie_list)
|
|
update_scan_status(movies_total=movie_total_count)
|
|
print(f"INFO: Found {movie_total_count} movies to process")
|
|
|
|
movie_count = 0
|
|
for item in movie_list:
|
|
# Check for shutdown signal at start of each movie
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if shutdown_event and shutdown_event.is_set():
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
|
return
|
|
|
|
movie_count += 1
|
|
update_scan_status(current_item=item.name, movies_processed=movie_count)
|
|
print(f"INFO: Processing movie: {item.name}")
|
|
try:
|
|
# Determine force_scan based on scan mode
|
|
force_scan = (scan_mode == "full")
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
|
|
movie_total += 1
|
|
if result == "skipped":
|
|
movie_skipped += 1
|
|
elif result == "processed":
|
|
movie_processed += 1
|
|
elif result == "no_video_files":
|
|
print(f"INFO: Skipped empty directory: {item.name}")
|
|
movie_skipped += 1
|
|
elif result == "shutdown":
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie scan gracefully")
|
|
return
|
|
except Exception as e:
|
|
print(f"ERROR: Failed processing movie {item}: {e}")
|
|
movie_total += 1
|
|
|
|
# Yield control every 2 movies to allow other requests (webhooks, web interface)
|
|
if movie_count % 2 == 0:
|
|
await asyncio.sleep(0.2) # 200ms yield to process other requests
|
|
print(f"INFO: Processed {movie_count} movies, yielding to other requests...")
|
|
|
|
# Check for shutdown signal
|
|
shutdown_event = dependencies.get("shutdown_event")
|
|
if shutdown_event and shutdown_event.is_set():
|
|
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
|
return
|
|
|
|
print(f"INFO: Completed movie scan: {movie_count} movies processed in {scan_path}")
|
|
|
|
# Log scan completion with duration
|
|
end_time = datetime.now()
|
|
duration = end_time - start_time
|
|
duration_str = str(duration).split('.')[0] # Remove microseconds
|
|
|
|
# Use same timezone logic as start
|
|
try:
|
|
tz_name = os.environ.get('TZ')
|
|
if tz_name:
|
|
# TZ is set, so datetime.now() already returns local time
|
|
local_end = end_time
|
|
tz_display = f" ({tz_name})"
|
|
else:
|
|
# No TZ set, assume container is UTC and convert to Eastern
|
|
import zoneinfo
|
|
local_tz = zoneinfo.ZoneInfo("America/New_York")
|
|
local_end = end_time.replace(tzinfo=timezone.utc).astimezone(local_tz)
|
|
tz_display = " (EDT/EST)"
|
|
except:
|
|
local_end = end_time
|
|
tz_display = " (container time)"
|
|
|
|
print(f"✅ MANUAL SCAN COMPLETED: {scan_type} scan (mode: {scan_mode}) finished at {local_end.strftime('%Y-%m-%d %H:%M:%S')}{tz_display}")
|
|
print(f"⏱️ MANUAL SCAN DURATION: {duration_str} (total time: {duration.total_seconds():.1f} seconds)")
|
|
|
|
# Stop scan tracking
|
|
stop_scan_tracking()
|
|
|
|
# Print optimization statistics for TV scans
|
|
if scan_type in ["both", "tv"] and tv_series_total > 0:
|
|
print(f"📊 TV SCAN OPTIMIZATION: Total: {tv_series_total}, Processed: {tv_series_processed}, Skipped: {tv_series_skipped}")
|
|
if tv_series_skipped > 0:
|
|
skip_percentage = (tv_series_skipped / tv_series_total) * 100
|
|
print(f"⚡ TV PERFORMANCE BOOST: {tv_series_skipped}/{tv_series_total} series skipped ({skip_percentage:.1f}% time saved!)")
|
|
|
|
# Print optimization statistics for movie scans
|
|
if scan_type in ["both", "movies"] and movie_total > 0:
|
|
print(f"📊 MOVIE SCAN OPTIMIZATION: Total: {movie_total}, Processed: {movie_processed}, Skipped: {movie_skipped}")
|
|
if movie_skipped > 0:
|
|
skip_percentage = (movie_skipped / movie_total) * 100
|
|
print(f"⚡ MOVIE PERFORMANCE BOOST: {movie_skipped}/{movie_total} movies skipped ({skip_percentage:.1f}% time saved!)")
|
|
|
|
# Print combined optimization statistics for "both" scans
|
|
if scan_type == "both" and (tv_series_total > 0 or movie_total > 0):
|
|
total_items = tv_series_total + movie_total
|
|
total_skipped = tv_series_skipped + movie_skipped
|
|
total_processed = tv_series_processed + movie_processed
|
|
if total_skipped > 0:
|
|
overall_skip_percentage = (total_skipped / total_items) * 100
|
|
print(f"🎯 OVERALL OPTIMIZATION: {total_skipped}/{total_items} items skipped ({overall_skip_percentage:.1f}% total time saved!)")
|
|
|
|
background_tasks.add_task(run_scan)
|
|
return {"status": "started", "message": f"Manual {scan_type} scan started (mode: {scan_mode})"}
|
|
|
|
|
|
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)}
|
|
|
|
|
|
# ---------------------------
|
|
# Database Cleanup Endpoints
|
|
# ---------------------------
|
|
|
|
async def delete_episode(imdb_id: str, season: int, episode: int, dependencies: dict):
|
|
"""Delete a specific episode from the database"""
|
|
db = dependencies["db"]
|
|
|
|
try:
|
|
deleted = db.delete_episode(imdb_id, season, episode)
|
|
|
|
if deleted:
|
|
return {
|
|
"success": True,
|
|
"message": f"Deleted episode S{season:02d}E{episode:02d} from series {imdb_id}",
|
|
"imdb_id": imdb_id,
|
|
"season": season,
|
|
"episode": episode
|
|
}
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"message": f"Episode S{season:02d}E{episode:02d} not found in series {imdb_id}",
|
|
"imdb_id": imdb_id,
|
|
"season": season,
|
|
"episode": episode
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"imdb_id": imdb_id,
|
|
"season": season,
|
|
"episode": episode
|
|
}
|
|
|
|
|
|
async def delete_series_episodes(imdb_id: str, dependencies: dict):
|
|
"""Delete all episodes for a series from the database"""
|
|
db = dependencies["db"]
|
|
|
|
try:
|
|
deleted_count = db.delete_series_episodes(imdb_id)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Deleted {deleted_count} episodes from series {imdb_id}",
|
|
"imdb_id": imdb_id,
|
|
"deleted_count": deleted_count
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"imdb_id": imdb_id
|
|
}
|
|
|
|
|
|
async def delete_movie(imdb_id: str, dependencies: dict):
|
|
"""Delete a specific movie from the database"""
|
|
db = dependencies["db"]
|
|
|
|
try:
|
|
deleted = db.delete_movie(imdb_id)
|
|
|
|
if deleted:
|
|
return {
|
|
"success": True,
|
|
"message": f"Deleted movie {imdb_id} from database",
|
|
"imdb_id": imdb_id
|
|
}
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"message": f"Movie {imdb_id} not found in database",
|
|
"imdb_id": imdb_id
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"imdb_id": imdb_id
|
|
}
|
|
|
|
|
|
async def cleanup_orphaned_episodes(dependencies: dict):
|
|
"""Find and delete episodes that don't have corresponding video files"""
|
|
db = dependencies["db"]
|
|
|
|
try:
|
|
deleted_episodes = db.delete_orphaned_episodes()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Cleaned up {len(deleted_episodes)} orphaned episodes",
|
|
"deleted_count": len(deleted_episodes),
|
|
"deleted_episodes": deleted_episodes
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to cleanup orphaned episodes"
|
|
}
|
|
|
|
|
|
async def cleanup_orphaned_movies(dependencies: dict):
|
|
"""Find and delete movies that don't have corresponding video files"""
|
|
db = dependencies["db"]
|
|
|
|
try:
|
|
deleted_movies = db.delete_orphaned_movies()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Cleaned up {len(deleted_movies)} orphaned movies",
|
|
"deleted_count": len(deleted_movies),
|
|
"deleted_movies": deleted_movies
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to cleanup orphaned movies"
|
|
}
|
|
|
|
|
|
async def cleanup_orphaned_series(dependencies: dict):
|
|
"""Find and delete TV series that don't have corresponding directories"""
|
|
db = dependencies["db"]
|
|
|
|
try:
|
|
deleted_series = db.delete_orphaned_series()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Cleaned up {len(deleted_series)} orphaned TV series",
|
|
"deleted_count": len(deleted_series),
|
|
"deleted_series": deleted_series
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to cleanup orphaned TV series"
|
|
}
|
|
|
|
|
|
async def verify_nfo_sync(dependencies: dict, media_type: str = "both"):
|
|
"""Verify that database dates match NFO file contents"""
|
|
db = dependencies["db"]
|
|
nfo_manager = dependencies["nfo_manager"]
|
|
config = dependencies["config"]
|
|
|
|
try:
|
|
verification_results = {
|
|
"movies": {"total": 0, "verified": 0, "missing_nfo": 0, "date_mismatch": 0, "empty_nfo": 0, "issues": []},
|
|
"episodes": {"total": 0, "verified": 0, "missing_nfo": 0, "date_mismatch": 0, "empty_nfo": 0, "issues": []}
|
|
}
|
|
|
|
print(f"🔍 NFO VERIFICATION STARTED: Checking {media_type}")
|
|
|
|
# Verify Movies
|
|
if media_type in ["both", "movies"]:
|
|
print("📽️ Verifying movie NFO files...")
|
|
|
|
# Get all movies from database
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT imdb_id, path, dateadded, released, source FROM movies WHERE has_video_file = true")
|
|
movies = cursor.fetchall()
|
|
|
|
verification_results["movies"]["total"] = len(movies)
|
|
|
|
for movie in movies:
|
|
imdb_id = movie['imdb_id']
|
|
db_path = movie['path']
|
|
db_dateadded = movie['dateadded']
|
|
db_released = movie['released']
|
|
db_source = movie['source']
|
|
|
|
try:
|
|
from pathlib import Path
|
|
movie_path = Path(db_path)
|
|
nfo_path = movie_path / "movie.nfo"
|
|
|
|
# Check if NFO exists
|
|
if not nfo_path.exists():
|
|
verification_results["movies"]["missing_nfo"] += 1
|
|
verification_results["movies"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"path": str(movie_path),
|
|
"issue": "missing_nfo",
|
|
"message": "NFO file does not exist"
|
|
})
|
|
continue
|
|
|
|
# Check if NFO is empty
|
|
nfo_content = nfo_path.read_text(encoding='utf-8').strip()
|
|
if not nfo_content:
|
|
verification_results["movies"]["empty_nfo"] += 1
|
|
verification_results["movies"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"path": str(movie_path),
|
|
"issue": "empty_nfo",
|
|
"message": "NFO file exists but is empty"
|
|
})
|
|
continue
|
|
|
|
# Extract NFOGuard data from NFO
|
|
nfo_data = nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
|
|
|
if not nfo_data:
|
|
verification_results["movies"]["date_mismatch"] += 1
|
|
verification_results["movies"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"path": str(movie_path),
|
|
"issue": "no_nfoguard_data",
|
|
"message": "NFO exists but contains no NFOGuard date information",
|
|
"db_dateadded": str(db_dateadded),
|
|
"db_source": db_source
|
|
})
|
|
continue
|
|
|
|
# Compare dates
|
|
nfo_dateadded = nfo_data.get("dateadded")
|
|
nfo_source = nfo_data.get("source")
|
|
|
|
# Convert database datetime to string for comparison
|
|
if hasattr(db_dateadded, 'isoformat'):
|
|
db_dateadded_str = db_dateadded.isoformat()
|
|
else:
|
|
db_dateadded_str = str(db_dateadded)
|
|
|
|
# Check for date mismatch
|
|
if nfo_dateadded != db_dateadded_str or nfo_source != db_source:
|
|
verification_results["movies"]["date_mismatch"] += 1
|
|
verification_results["movies"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"path": str(movie_path),
|
|
"issue": "date_mismatch",
|
|
"message": "Database and NFO dates/sources don't match",
|
|
"db_dateadded": db_dateadded_str,
|
|
"db_source": db_source,
|
|
"nfo_dateadded": nfo_dateadded,
|
|
"nfo_source": nfo_source
|
|
})
|
|
continue
|
|
|
|
# Everything matches
|
|
verification_results["movies"]["verified"] += 1
|
|
|
|
except Exception as e:
|
|
verification_results["movies"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"path": db_path,
|
|
"issue": "verification_error",
|
|
"message": f"Error during verification: {str(e)}"
|
|
})
|
|
|
|
# Verify TV Episodes
|
|
if media_type in ["both", "episodes"]:
|
|
print("📺 Verifying TV episode NFO files...")
|
|
|
|
# Get all episodes from database
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT imdb_id, season, episode, air_date, dateadded, source, video_path
|
|
FROM episodes
|
|
WHERE has_video_file = true
|
|
ORDER BY imdb_id, season, episode
|
|
""")
|
|
episodes = cursor.fetchall()
|
|
|
|
verification_results["episodes"]["total"] = len(episodes)
|
|
|
|
for episode in episodes:
|
|
imdb_id = episode['imdb_id']
|
|
season = episode['season']
|
|
episode_num = episode['episode']
|
|
db_air_date = episode['air_date']
|
|
db_dateadded = episode['dateadded']
|
|
db_source = episode['source']
|
|
video_path = episode['video_path']
|
|
|
|
try:
|
|
from pathlib import Path
|
|
if video_path:
|
|
video_file = Path(video_path)
|
|
nfo_path = video_file.with_suffix('.nfo')
|
|
else:
|
|
# Construct expected path
|
|
for tv_path in config.tv_paths:
|
|
series_dirs = [d for d in tv_path.iterdir() if d.is_dir() and imdb_id in str(d)]
|
|
if series_dirs:
|
|
season_dir = series_dirs[0] / f"Season {season:02d}"
|
|
if season_dir.exists():
|
|
episode_files = list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.nfo"))
|
|
if episode_files:
|
|
nfo_path = episode_files[0]
|
|
break
|
|
else:
|
|
continue
|
|
|
|
# Check if NFO exists
|
|
if not nfo_path.exists():
|
|
verification_results["episodes"]["missing_nfo"] += 1
|
|
verification_results["episodes"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
|
"issue": "missing_nfo",
|
|
"message": "NFO file does not exist",
|
|
"expected_path": str(nfo_path)
|
|
})
|
|
continue
|
|
|
|
# Check if NFO is empty
|
|
nfo_content = nfo_path.read_text(encoding='utf-8').strip()
|
|
if not nfo_content:
|
|
verification_results["episodes"]["empty_nfo"] += 1
|
|
verification_results["episodes"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
|
"issue": "empty_nfo",
|
|
"message": "NFO file exists but is empty",
|
|
"nfo_path": str(nfo_path)
|
|
})
|
|
continue
|
|
|
|
# Extract NFOGuard data from episode NFO
|
|
nfo_data = nfo_manager.extract_nfoguard_dates_from_episode_nfo(nfo_path)
|
|
|
|
if not nfo_data:
|
|
verification_results["episodes"]["date_mismatch"] += 1
|
|
verification_results["episodes"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
|
"issue": "no_nfoguard_data",
|
|
"message": "NFO exists but contains no NFOGuard date information",
|
|
"db_dateadded": str(db_dateadded),
|
|
"db_source": db_source,
|
|
"nfo_path": str(nfo_path)
|
|
})
|
|
continue
|
|
|
|
# Everything matches
|
|
verification_results["episodes"]["verified"] += 1
|
|
|
|
except Exception as e:
|
|
verification_results["episodes"]["issues"].append({
|
|
"imdb_id": imdb_id,
|
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
|
"issue": "verification_error",
|
|
"message": f"Error during verification: {str(e)}"
|
|
})
|
|
|
|
# Print summary
|
|
if media_type in ["both", "movies"]:
|
|
movies = verification_results["movies"]
|
|
print(f"📽️ MOVIE VERIFICATION COMPLETE:")
|
|
print(f" Total Movies: {movies['total']}")
|
|
print(f" ✅ Verified: {movies['verified']}")
|
|
print(f" ❌ Missing NFO: {movies['missing_nfo']}")
|
|
print(f" 📄 Empty NFO: {movies['empty_nfo']}")
|
|
print(f" 🔄 Date Mismatch: {movies['date_mismatch']}")
|
|
|
|
if media_type in ["both", "episodes"]:
|
|
episodes = verification_results["episodes"]
|
|
print(f"📺 EPISODE VERIFICATION COMPLETE:")
|
|
print(f" Total Episodes: {episodes['total']}")
|
|
print(f" ✅ Verified: {episodes['verified']}")
|
|
print(f" ❌ Missing NFO: {episodes['missing_nfo']}")
|
|
print(f" 📄 Empty NFO: {episodes['empty_nfo']}")
|
|
print(f" 🔄 Date Mismatch: {episodes['date_mismatch']}")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"NFO verification completed for {media_type}",
|
|
"results": verification_results
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to verify NFO files"
|
|
}
|
|
|
|
|
|
async def fix_nfo_sync_issues(dependencies: dict, media_type: str = "both"):
|
|
"""Fix NFO sync issues by regenerating NFO files from database data"""
|
|
db = dependencies["db"]
|
|
nfo_manager = dependencies["nfo_manager"]
|
|
config = dependencies["config"]
|
|
|
|
try:
|
|
# First run verification to identify issues
|
|
verification_result = await verify_nfo_sync(dependencies, media_type)
|
|
if not verification_result["success"]:
|
|
return verification_result
|
|
|
|
results = verification_result["results"]
|
|
fix_results = {
|
|
"movies": {"fixed": 0, "failed": 0, "errors": []},
|
|
"episodes": {"fixed": 0, "failed": 0, "errors": []}
|
|
}
|
|
|
|
print(f"🔧 NFO FIX STARTED: Regenerating NFO files for {media_type}")
|
|
|
|
# Fix Movies
|
|
if media_type in ["both", "movies"]:
|
|
print("📽️ Fixing movie NFO files...")
|
|
|
|
for issue in results["movies"]["issues"]:
|
|
if issue["issue"] in ["empty_nfo", "no_nfoguard_data", "date_mismatch"]:
|
|
imdb_id = issue["imdb_id"]
|
|
movie_path = issue["path"]
|
|
|
|
try:
|
|
# Get movie data from database
|
|
movie_data = db.get_movie_dates(imdb_id)
|
|
if not movie_data:
|
|
fix_results["movies"]["errors"].append(f"No database data for {imdb_id}")
|
|
fix_results["movies"]["failed"] += 1
|
|
continue
|
|
|
|
dateadded = movie_data.get("dateadded")
|
|
released = movie_data.get("released")
|
|
source = movie_data.get("source")
|
|
|
|
# Convert datetime to string if needed
|
|
if hasattr(dateadded, 'isoformat'):
|
|
dateadded = dateadded.isoformat()
|
|
if released and hasattr(released, 'isoformat'):
|
|
released = released.isoformat()
|
|
|
|
# Regenerate NFO file
|
|
from pathlib import Path
|
|
movie_path_obj = Path(movie_path)
|
|
|
|
if config.manage_nfo:
|
|
nfo_manager.create_movie_nfo(
|
|
movie_path_obj, imdb_id, dateadded, released, source, config.lock_metadata
|
|
)
|
|
print(f"✅ Fixed NFO for {imdb_id}: {movie_path}")
|
|
fix_results["movies"]["fixed"] += 1
|
|
else:
|
|
fix_results["movies"]["errors"].append(f"MANAGE_NFO disabled - cannot fix {imdb_id}")
|
|
fix_results["movies"]["failed"] += 1
|
|
|
|
except Exception as e:
|
|
fix_results["movies"]["errors"].append(f"Error fixing {imdb_id}: {str(e)}")
|
|
fix_results["movies"]["failed"] += 1
|
|
|
|
# Fix Episodes
|
|
if media_type in ["both", "episodes"]:
|
|
print("📺 Fixing TV episode NFO files...")
|
|
|
|
for issue in results["episodes"]["issues"]:
|
|
if issue["issue"] in ["empty_nfo", "no_nfoguard_data", "date_mismatch"]:
|
|
imdb_id = issue["imdb_id"]
|
|
episode_str = issue["episode"]
|
|
|
|
try:
|
|
# Parse season/episode from string like "S01E05"
|
|
import re
|
|
match = re.match(r'S(\d+)E(\d+)', episode_str)
|
|
if not match:
|
|
continue
|
|
|
|
season = int(match.group(1))
|
|
episode_num = int(match.group(2))
|
|
|
|
# Get episode data from database
|
|
episode_data = db.get_episode_date(imdb_id, season, episode_num)
|
|
if not episode_data:
|
|
fix_results["episodes"]["errors"].append(f"No database data for {episode_str}")
|
|
fix_results["episodes"]["failed"] += 1
|
|
continue
|
|
|
|
air_date = episode_data.get("air_date")
|
|
dateadded = episode_data.get("dateadded")
|
|
source = episode_data.get("source")
|
|
video_path = episode_data.get("video_path")
|
|
|
|
# Convert datetime to string if needed
|
|
if hasattr(air_date, 'isoformat'):
|
|
air_date = air_date.isoformat()
|
|
if hasattr(dateadded, 'isoformat'):
|
|
dateadded = dateadded.isoformat()
|
|
|
|
# Find the episode file
|
|
from pathlib import Path
|
|
if video_path:
|
|
episode_file = Path(video_path)
|
|
else:
|
|
# Try to find it by scanning
|
|
episode_file = None
|
|
for tv_path in config.tv_paths:
|
|
series_dirs = [d for d in tv_path.iterdir() if d.is_dir() and imdb_id in str(d)]
|
|
if series_dirs:
|
|
season_dir = series_dirs[0] / f"Season {season:02d}"
|
|
if season_dir.exists():
|
|
video_files = list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.mkv")) + \
|
|
list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.mp4")) + \
|
|
list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.avi"))
|
|
if video_files:
|
|
episode_file = video_files[0]
|
|
break
|
|
|
|
if not episode_file or not episode_file.exists():
|
|
fix_results["episodes"]["errors"].append(f"Cannot find video file for {episode_str}")
|
|
fix_results["episodes"]["failed"] += 1
|
|
continue
|
|
|
|
# Regenerate NFO file
|
|
if config.manage_nfo:
|
|
nfo_manager.create_episode_nfo(
|
|
episode_file, imdb_id, season, episode_num, air_date, dateadded, source, config.lock_metadata
|
|
)
|
|
print(f"✅ Fixed NFO for {episode_str}: {episode_file}")
|
|
fix_results["episodes"]["fixed"] += 1
|
|
else:
|
|
fix_results["episodes"]["errors"].append(f"MANAGE_NFO disabled - cannot fix {episode_str}")
|
|
fix_results["episodes"]["failed"] += 1
|
|
|
|
except Exception as e:
|
|
fix_results["episodes"]["errors"].append(f"Error fixing {episode_str}: {str(e)}")
|
|
fix_results["episodes"]["failed"] += 1
|
|
|
|
# Print summary
|
|
movies = fix_results["movies"]
|
|
episodes = fix_results["episodes"]
|
|
|
|
print(f"🔧 NFO FIX COMPLETED:")
|
|
if media_type in ["both", "movies"]:
|
|
print(f" 📽️ Movies: {movies['fixed']} fixed, {movies['failed']} failed")
|
|
if media_type in ["both", "episodes"]:
|
|
print(f" 📺 Episodes: {episodes['fixed']} fixed, {episodes['failed']} failed")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"NFO fix completed for {media_type}",
|
|
"fix_results": fix_results,
|
|
"original_verification": results
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to fix NFO sync issues"
|
|
}
|
|
|
|
|
|
async def backfill_movie_release_dates(dependencies: dict):
|
|
"""Backfill missing release dates for existing movies"""
|
|
db = dependencies["db"]
|
|
movie_processor = dependencies["movie_processor"]
|
|
|
|
try:
|
|
# Get all movies with missing release dates
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Find movies where released is NULL but source suggests we found a release date
|
|
query = """
|
|
SELECT imdb_id, path, dateadded, source
|
|
FROM movies
|
|
WHERE released IS NULL
|
|
AND (source LIKE '%tmdb%' OR source LIKE '%omdb%' OR source LIKE '%premiered%')
|
|
ORDER BY last_updated DESC
|
|
"""
|
|
cursor.execute(query)
|
|
movies_to_backfill = cursor.fetchall()
|
|
|
|
if not movies_to_backfill:
|
|
return {
|
|
"success": True,
|
|
"message": "No movies found that need release date backfill",
|
|
"movies_processed": 0,
|
|
"movies_updated": 0
|
|
}
|
|
|
|
processed_count = 0
|
|
updated_count = 0
|
|
|
|
print(f"🔄 BACKFILL STARTED: Found {len(movies_to_backfill)} movies needing release date backfill")
|
|
|
|
for movie in movies_to_backfill:
|
|
imdb_id = movie['imdb_id']
|
|
source = movie['source']
|
|
|
|
try:
|
|
print(f"🔍 Processing {imdb_id} (source: {source})")
|
|
|
|
# Try to get release date based on the source
|
|
release_date = None
|
|
|
|
if 'tmdb' in source or 'omdb' in source:
|
|
# Re-fetch digital release date
|
|
digital_date, _ = movie_processor._get_digital_release_date(imdb_id)
|
|
if digital_date:
|
|
release_date = digital_date
|
|
print(f"✅ Found release date for {imdb_id}: {release_date}")
|
|
else:
|
|
print(f"⚠️ Could not re-fetch release date for {imdb_id}")
|
|
|
|
elif 'premiered' in source:
|
|
# Use the dateadded as the release date for premiered sources
|
|
release_date = movie['dateadded']
|
|
if hasattr(release_date, 'isoformat'):
|
|
release_date = release_date.isoformat()
|
|
print(f"✅ Using premiered date for {imdb_id}: {release_date}")
|
|
|
|
# Update the database if we found a release date
|
|
if release_date:
|
|
db.upsert_movie_dates(imdb_id, release_date, movie['dateadded'], source, True)
|
|
updated_count += 1
|
|
print(f"📝 Updated release date for {imdb_id}")
|
|
|
|
processed_count += 1
|
|
|
|
# Small delay to avoid overwhelming APIs
|
|
import time
|
|
time.sleep(0.1)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error processing {imdb_id}: {e}")
|
|
processed_count += 1
|
|
continue
|
|
|
|
print(f"✅ BACKFILL COMPLETED: Processed {processed_count} movies, updated {updated_count} with release dates")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Backfill completed: processed {processed_count} movies, updated {updated_count} with release dates",
|
|
"movies_processed": processed_count,
|
|
"movies_updated": updated_count,
|
|
"movies_found": len(movies_to_backfill)
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to backfill movie release dates"
|
|
}
|
|
|
|
|
|
# ---------------------------
|
|
# Scan Status Functions
|
|
# ---------------------------
|
|
|
|
async def get_scan_status():
|
|
"""Get detailed scan status with progress information"""
|
|
global scan_status
|
|
|
|
if not scan_status["scanning"]:
|
|
return {"scanning": False, "message": "No active scan"}
|
|
|
|
# Calculate elapsed time
|
|
from datetime import datetime
|
|
if scan_status["start_time"]:
|
|
elapsed_seconds = int((datetime.now() - scan_status["start_time"]).total_seconds())
|
|
if elapsed_seconds >= 60:
|
|
minutes = elapsed_seconds // 60
|
|
seconds = elapsed_seconds % 60
|
|
elapsed_str = f"{minutes}m {seconds}s"
|
|
else:
|
|
elapsed_str = f"{elapsed_seconds}s"
|
|
else:
|
|
elapsed_str = "unknown"
|
|
|
|
# Build detailed status message
|
|
if scan_status["current_operation"] == "tv":
|
|
if scan_status["tv_series_total"] > 0:
|
|
message = f"Processing TV series ({scan_status['tv_series_processed']}/{scan_status['tv_series_total']}) - {elapsed_str} elapsed"
|
|
else:
|
|
message = f"Processed {scan_status['tv_series_processed']} TV series - {elapsed_str} elapsed"
|
|
elif scan_status["current_operation"] == "movies":
|
|
if scan_status["movies_total"] > 0:
|
|
message = f"Processing movies ({scan_status['movies_processed']}/{scan_status['movies_total']}) - {elapsed_str} elapsed"
|
|
else:
|
|
message = f"Processed {scan_status['movies_processed']} movies - {elapsed_str} elapsed"
|
|
else:
|
|
message = f"Scan in progress - {elapsed_str} elapsed"
|
|
|
|
# Add current item if available
|
|
if scan_status["current_item"]:
|
|
message += f" | Current: {scan_status['current_item']}"
|
|
|
|
return {
|
|
"scanning": True,
|
|
"message": message,
|
|
"scan_type": scan_status["scan_type"],
|
|
"scan_mode": scan_status["scan_mode"],
|
|
"elapsed_seconds": elapsed_seconds if scan_status["start_time"] else 0,
|
|
"current_operation": scan_status["current_operation"],
|
|
"tv_series_processed": scan_status["tv_series_processed"],
|
|
"tv_series_total": scan_status["tv_series_total"],
|
|
"tv_series_skipped": scan_status["tv_series_skipped"],
|
|
"movies_processed": scan_status["movies_processed"],
|
|
"movies_total": scan_status["movies_total"],
|
|
"movies_skipped": scan_status["movies_skipped"],
|
|
"current_item": scan_status["current_item"]
|
|
}
|
|
|
|
def update_scan_status(operation=None, current_item=None, **kwargs):
|
|
"""Update scan status with new progress information"""
|
|
global scan_status
|
|
|
|
if operation:
|
|
scan_status["current_operation"] = operation
|
|
if current_item:
|
|
scan_status["current_item"] = current_item
|
|
|
|
# Update counters
|
|
for key, value in kwargs.items():
|
|
if key in scan_status:
|
|
scan_status[key] = value
|
|
|
|
scan_status["last_update"] = datetime.now()
|
|
|
|
def start_scan_tracking(scan_type, scan_mode):
|
|
"""Initialize scan tracking"""
|
|
global scan_status
|
|
|
|
scan_status.update({
|
|
"scanning": True,
|
|
"scan_type": scan_type,
|
|
"scan_mode": scan_mode,
|
|
"start_time": datetime.now(),
|
|
"current_operation": None,
|
|
"tv_series_processed": 0,
|
|
"tv_series_total": 0,
|
|
"tv_series_skipped": 0,
|
|
"movies_processed": 0,
|
|
"movies_total": 0,
|
|
"movies_skipped": 0,
|
|
"current_item": None,
|
|
"last_update": datetime.now()
|
|
})
|
|
|
|
def stop_scan_tracking():
|
|
"""Stop scan tracking"""
|
|
global scan_status
|
|
|
|
scan_status.update({
|
|
"scanning": False,
|
|
"scan_type": None,
|
|
"scan_mode": None,
|
|
"start_time": None,
|
|
"current_operation": None,
|
|
"current_item": None
|
|
})
|
|
|
|
# ---------------------------
|
|
# Route Registration
|
|
# ---------------------------
|
|
|
|
async def update_episode_nfo(imdb_id: str, season: int, episode: int, request: Request, dependencies: dict):
|
|
"""Update NFO file for a specific episode with new dateadded"""
|
|
try:
|
|
# Get request data
|
|
payload = await request.json()
|
|
dateadded = payload.get('dateadded')
|
|
source = payload.get('source', 'manual')
|
|
aired = payload.get('aired')
|
|
|
|
# Get dependencies
|
|
config = dependencies["config"]
|
|
nfo_manager = dependencies["nfo_manager"]
|
|
|
|
if not config.manage_nfo:
|
|
return {"success": False, "message": "NFO management is disabled"}
|
|
|
|
# Find the series directory based on IMDb ID
|
|
series_path = None
|
|
for tv_path in config.tv_paths:
|
|
for series_dir in Path(tv_path).iterdir():
|
|
if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower():
|
|
series_path = series_dir
|
|
break
|
|
if series_path:
|
|
break
|
|
|
|
if not series_path:
|
|
return {"success": False, "message": f"Series directory not found for {imdb_id}"}
|
|
|
|
# Get season directory
|
|
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
|
if not season_dir.exists():
|
|
return {"success": False, "message": f"Season directory not found: {season_dir}"}
|
|
|
|
# Update NFO file
|
|
nfo_manager.create_episode_nfo(
|
|
season_dir=season_dir,
|
|
season_num=season,
|
|
episode_num=episode,
|
|
aired=aired,
|
|
dateadded=dateadded,
|
|
source=source,
|
|
lock_metadata=config.lock_metadata
|
|
)
|
|
|
|
print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}")
|
|
return {"success": True, "message": f"NFO file updated for {imdb_id} S{season:02d}E{episode:02d}"}
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
|
return {"success": False, "message": f"Failed to update NFO file: {str(e)}"}
|
|
|
|
|
|
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.post("/webhook/maintainarr")
|
|
async def _maintainarr_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
return await maintainarr_webhook(request, background_tasks, dependencies)
|
|
|
|
@app.get("/health")
|
|
async def _health() -> HealthResponse:
|
|
return await health(dependencies)
|
|
|
|
@app.get("/health/simple")
|
|
async def _health_simple():
|
|
"""Simple health check for Docker without external dependencies"""
|
|
return {"status": "healthy", "service": "nfoguard-core"}
|
|
|
|
@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.delete("/database/episode/{imdb_id}/{season}/{episode}")
|
|
async def _delete_episode(imdb_id: str, season: int, episode: int):
|
|
return await delete_episode(imdb_id, season, episode, dependencies)
|
|
|
|
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-nfo")
|
|
async def _update_episode_nfo(imdb_id: str, season: int, episode: int, request: Request):
|
|
return await update_episode_nfo(imdb_id, season, episode, request, dependencies)
|
|
|
|
@app.delete("/database/series/{imdb_id}/episodes")
|
|
async def _delete_series_episodes(imdb_id: str):
|
|
return await delete_series_episodes(imdb_id, dependencies)
|
|
|
|
@app.delete("/database/movie/{imdb_id}")
|
|
async def _delete_movie(imdb_id: str):
|
|
return await delete_movie(imdb_id, dependencies)
|
|
|
|
@app.post("/database/cleanup/orphaned-episodes")
|
|
async def _cleanup_orphaned_episodes():
|
|
return await cleanup_orphaned_episodes(dependencies)
|
|
|
|
@app.post("/database/cleanup/orphaned-movies")
|
|
async def _cleanup_orphaned_movies():
|
|
return await cleanup_orphaned_movies(dependencies)
|
|
|
|
@app.post("/database/cleanup/orphaned-series")
|
|
async def _cleanup_orphaned_series():
|
|
return await cleanup_orphaned_series(dependencies)
|
|
|
|
@app.post("/database/backfill/movie-release-dates")
|
|
async def _backfill_movie_release_dates():
|
|
return await backfill_movie_release_dates(dependencies)
|
|
|
|
@app.post("/database/verify/nfo-sync")
|
|
async def _verify_nfo_sync(media_type: str = "both"):
|
|
return await verify_nfo_sync(dependencies, media_type)
|
|
|
|
@app.post("/database/fix/nfo-sync")
|
|
async def _fix_nfo_sync_issues(media_type: str = "both"):
|
|
return await fix_nfo_sync_issues(dependencies, media_type)
|
|
|
|
@app.post("/manual/scan")
|
|
async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", scan_mode: str = "smart"):
|
|
return await manual_scan(background_tasks, path, scan_type, scan_mode, dependencies)
|
|
|
|
@app.get("/api/scan/status")
|
|
async def _scan_status():
|
|
return await get_scan_status()
|
|
|
|
@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)
|
|
|
|
# Include monitoring routes
|
|
from api.monitoring_routes import router as monitoring_router
|
|
app.include_router(monitoring_router)
|
|
|
|
# ---------------------------
|
|
# Web Interface Moved to Separate Container
|
|
# ---------------------------
|
|
# Web interface routes have been moved to the nfoguard-web container
|
|
# for performance isolation. The core container only handles:
|
|
# - Webhooks (/webhook/*)
|
|
# - Manual scans (/manual/*)
|
|
# - Database operations (/database/*)
|
|
# - Health checks (/health)
|
|
#
|
|
# Web interface available on separate container port 8081
|
|
|
|
async def nfo_repair_scan():
|
|
"""Scan filesystem for episodes/movies missing dateadded elements in NFO files"""
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
|
|
config = dependencies.get("config")
|
|
db = dependencies.get("db")
|
|
|
|
if not config or not db:
|
|
raise HTTPException(status_code=500, detail=f"Dependencies not available - config:{config is not None}, db:{db is not None}")
|
|
|
|
print("🔧 Starting NFO repair scan from core container")
|
|
|
|
import time
|
|
scan_start_time = time.time()
|
|
|
|
missing_items = {
|
|
"episodes": [],
|
|
"movies": []
|
|
}
|
|
|
|
# Track total files processed for statistics
|
|
total_tv_files_checked = 0
|
|
total_movie_files_checked = 0
|
|
|
|
try:
|
|
# Use filesystem-first approach: find all NFO files missing dateadded
|
|
print("📺 Scanning NFO files directly for missing dateadded elements")
|
|
|
|
# Get TV and Movie library paths from config
|
|
tv_path = getattr(config, 'tv_library_path', '/media/TV')
|
|
movie_path = getattr(config, 'movie_library_path', '/media/Movies')
|
|
|
|
print(f"📁 Scanning TV path: {tv_path}")
|
|
print(f"📁 Scanning Movie path: {movie_path}")
|
|
|
|
import subprocess
|
|
import re
|
|
|
|
# Separate tracking for TV and movie missing files
|
|
missing_tv_nfo_files = []
|
|
missing_movie_nfo_files = []
|
|
|
|
# Find all NFO files in TV directory
|
|
if os.path.exists(tv_path):
|
|
print("🔍 Finding all TV NFO files...")
|
|
try:
|
|
# Use find to get all NFO files
|
|
find_result = subprocess.run(
|
|
['find', tv_path, '-name', '*.nfo', '-type', 'f'],
|
|
capture_output=True, text=True, timeout=60
|
|
)
|
|
|
|
if find_result.returncode == 0:
|
|
tv_nfo_files = find_result.stdout.strip().split('\n')
|
|
tv_nfo_files = [f for f in tv_nfo_files if f] # Remove empty strings
|
|
print(f"📊 Found {len(tv_nfo_files)} TV NFO files")
|
|
|
|
# Check each NFO file for missing dateadded
|
|
for i, nfo_file in enumerate(tv_nfo_files):
|
|
total_tv_files_checked = i + 1 # Track progress
|
|
|
|
if i % 1000 == 0 and i > 0:
|
|
elapsed = time.time() - scan_start_time
|
|
print(f"📊 Progress: {i}/{len(tv_nfo_files)} TV NFO files checked in {elapsed:.1f}s, {len(missing_tv_nfo_files)} missing found")
|
|
|
|
# Timeout check
|
|
if time.time() - scan_start_time > 180: # 3 minutes
|
|
print(f"⏰ TV NFO scan timeout after 3 minutes - checked {i}/{len(tv_nfo_files)} files")
|
|
break
|
|
|
|
try:
|
|
# Use grep to quickly check if dateadded exists
|
|
grep_result = subprocess.run(
|
|
['grep', '-l', '<dateadded>', nfo_file],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
|
|
# If grep returns non-zero, dateadded is missing
|
|
if grep_result.returncode != 0:
|
|
missing_tv_nfo_files.append(nfo_file)
|
|
|
|
# Log specific files we're looking for
|
|
if 'Hudson' in nfo_file and 'S08E05' in nfo_file:
|
|
print(f"🔍 FOUND MISSING: Hudson & Rex S08E05 at {nfo_file}")
|
|
if 'Star Trek' in nfo_file and 'S03E07' in nfo_file:
|
|
print(f"🔍 FOUND MISSING: Star Trek SNW S03E07 at {nfo_file}")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f"⏰ Timeout checking {nfo_file}")
|
|
continue
|
|
except Exception as e:
|
|
print(f"⚠️ Error checking {nfo_file}: {e}")
|
|
continue
|
|
|
|
else:
|
|
print(f"❌ Error finding TV NFO files: {find_result.stderr}")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print("⏰ Find command timeout for TV files")
|
|
except Exception as e:
|
|
print(f"❌ Error scanning TV directory: {e}")
|
|
else:
|
|
print(f"❌ TV path does not exist: {tv_path}")
|
|
|
|
print(f"📊 Found {len(missing_tv_nfo_files)} TV NFO files missing dateadded elements")
|
|
|
|
# Convert TV file paths to episode information with direct NFO parsing
|
|
def extract_imdb_from_nfo_content(nfo_file, is_episode=True, verbose_logging=True):
|
|
"""Extract IMDb ID directly from NFO file content - we already know the file exists"""
|
|
imdb_id = "unknown"
|
|
|
|
try:
|
|
# Try to parse the NFO file we already know exists (since grep found it missing dateadded)
|
|
tree = ET.parse(nfo_file)
|
|
root = tree.getroot()
|
|
|
|
# Method 1: Check <imdb> tag
|
|
imdb_elem = root.find("imdb")
|
|
if imdb_elem is not None and imdb_elem.text:
|
|
imdb_text = imdb_elem.text.strip()
|
|
if imdb_text.startswith('tt'):
|
|
if verbose_logging:
|
|
print(f"✅ Found IMDb {imdb_text} in <imdb> tag: {os.path.basename(nfo_file)}")
|
|
return imdb_text
|
|
|
|
# Method 2: Check <uniqueid type="imdb"> tags
|
|
for uniqueid in root.findall("uniqueid"):
|
|
if uniqueid.get("type") == "imdb" and uniqueid.text:
|
|
imdb_text = uniqueid.text.strip()
|
|
if imdb_text.startswith('tt'):
|
|
if verbose_logging:
|
|
print(f"✅ Found IMDb {imdb_text} in <uniqueid type='imdb'>: {os.path.basename(nfo_file)}")
|
|
return imdb_text
|
|
|
|
# Method 3: Check for IMDb pattern anywhere in NFO content
|
|
nfo_content = ET.tostring(root, encoding='unicode')
|
|
content_match = re.search(r'(tt\d{7,})', nfo_content)
|
|
if content_match:
|
|
imdb_text = content_match.group(1)
|
|
if verbose_logging:
|
|
print(f"✅ Found IMDb {imdb_text} in NFO content: {os.path.basename(nfo_file)}")
|
|
return imdb_text
|
|
|
|
except ET.ParseError as e:
|
|
# Handle malformed XML by trying text-based search
|
|
if verbose_logging:
|
|
print(f"⚠️ NFO XML parse error in {os.path.basename(nfo_file)}: {e}")
|
|
try:
|
|
# Fallback: Read as text and search for IMDb patterns
|
|
with open(nfo_file, 'r', encoding='utf-8', errors='ignore') as f:
|
|
nfo_text = f.read()
|
|
|
|
# Search for IMDb patterns in raw text
|
|
text_match = re.search(r'(tt\d{7,})', nfo_text)
|
|
if text_match:
|
|
imdb_text = text_match.group(1)
|
|
if verbose_logging:
|
|
print(f"✅ Found IMDb {imdb_text} in NFO text content: {os.path.basename(nfo_file)}")
|
|
return imdb_text
|
|
except Exception as text_error:
|
|
if verbose_logging:
|
|
print(f"⚠️ Error reading NFO as text {nfo_file}: {text_error}")
|
|
|
|
except Exception as e:
|
|
if verbose_logging:
|
|
print(f"⚠️ Unexpected error parsing NFO {nfo_file}: {e}")
|
|
|
|
# Method 4: Fallback - check folder structure
|
|
if is_episode:
|
|
series_folder = os.path.dirname(os.path.dirname(nfo_file))
|
|
folder_name = os.path.basename(series_folder)
|
|
folder_match = re.search(r'\[imdb-(tt\d+)\]', folder_name, re.IGNORECASE)
|
|
if folder_match:
|
|
if verbose_logging:
|
|
print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}")
|
|
return folder_match.group(1)
|
|
else:
|
|
# For movies, check movie folder
|
|
movie_folder = os.path.dirname(nfo_file)
|
|
folder_name = os.path.basename(movie_folder)
|
|
folder_match = re.search(r'\[imdb-(tt\d+)\]', folder_name, re.IGNORECASE)
|
|
if folder_match:
|
|
if verbose_logging:
|
|
print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}")
|
|
return folder_match.group(1)
|
|
|
|
if verbose_logging:
|
|
print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}")
|
|
return "unknown"
|
|
|
|
for nfo_file in missing_tv_nfo_files:
|
|
try:
|
|
# Parse episode info from file path
|
|
# Example: /media/TV/tv/Hudson & Rex (2019) [imdb-tt9111220]/Season 08/Hudson & Rex (2019)-S08E05-Episode.nfo
|
|
path_parts = nfo_file.split('/')
|
|
|
|
# Look for season/episode pattern
|
|
season_match = re.search(r'Season\s+(\d+)', nfo_file, re.IGNORECASE)
|
|
episode_match = re.search(r'S(\d+)E(\d+)', os.path.basename(nfo_file), re.IGNORECASE)
|
|
|
|
if season_match and episode_match:
|
|
season = int(episode_match.group(1))
|
|
episode = int(episode_match.group(2))
|
|
|
|
# Extract series name and path
|
|
series_path = os.path.dirname(os.path.dirname(nfo_file))
|
|
series_name = os.path.basename(series_path)
|
|
|
|
# Extract IMDb ID from NFO content
|
|
imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=True)
|
|
|
|
# Clean series name (remove year and imdb parts)
|
|
clean_series_name = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', series_name).strip()
|
|
|
|
missing_items["episodes"].append({
|
|
"imdb_id": imdb_id,
|
|
"season": season,
|
|
"episode": episode,
|
|
"series_name": clean_series_name,
|
|
"series_path": series_path,
|
|
"dateadded": None,
|
|
"nfo_path": nfo_file,
|
|
"reason": "NFO missing dateadded element (filesystem scan)"
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Error parsing NFO file path {nfo_file}: {e}")
|
|
continue
|
|
|
|
# Scan movies using filesystem grep approach
|
|
print("🎬 Scanning movie NFO files for missing dateadded elements")
|
|
|
|
# Find all NFO files in Movie directory
|
|
if os.path.exists(movie_path):
|
|
print(f"🔍 Finding all Movie NFO files in {movie_path}...")
|
|
try:
|
|
# Use find to get all NFO files
|
|
find_result = subprocess.run(
|
|
['find', movie_path, '-name', '*.nfo', '-type', 'f'],
|
|
capture_output=True, text=True, timeout=60
|
|
)
|
|
|
|
if find_result.returncode == 0:
|
|
movie_nfo_files = find_result.stdout.strip().split('\n')
|
|
movie_nfo_files = [f for f in movie_nfo_files if f] # Remove empty strings
|
|
print(f"📊 Found {len(movie_nfo_files)} Movie NFO files")
|
|
|
|
# Check each NFO file for missing dateadded
|
|
for i, nfo_file in enumerate(movie_nfo_files):
|
|
total_movie_files_checked = i + 1 # Track progress
|
|
|
|
if i % 500 == 0 and i > 0:
|
|
elapsed = time.time() - scan_start_time
|
|
print(f"📊 Progress: {i}/{len(movie_nfo_files)} Movie NFO files checked in {elapsed:.1f}s, {len(missing_movie_nfo_files)} missing found")
|
|
|
|
# Timeout check
|
|
if time.time() - scan_start_time > 300: # 5 minutes total
|
|
print(f"⏰ Movie NFO scan timeout after 5 minutes - checked {i}/{len(movie_nfo_files)} files")
|
|
break
|
|
|
|
try:
|
|
# Use grep to quickly check if dateadded exists
|
|
grep_result = subprocess.run(
|
|
['grep', '-l', '<dateadded>', nfo_file],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
|
|
# If grep returns non-zero, dateadded is missing
|
|
if grep_result.returncode != 0:
|
|
missing_movie_nfo_files.append(nfo_file)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f"⏰ Timeout checking {nfo_file}")
|
|
continue
|
|
except Exception as e:
|
|
print(f"⚠️ Error checking {nfo_file}: {e}")
|
|
continue
|
|
|
|
else:
|
|
print(f"❌ Error finding Movie NFO files: {find_result.stderr}")
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print("⏰ Find command timeout for Movie files")
|
|
except Exception as e:
|
|
print(f"❌ Error scanning Movie directory: {e}")
|
|
else:
|
|
print(f"❌ Movie path does not exist: {movie_path}")
|
|
|
|
# Process missing movie NFO files
|
|
print(f"📊 Found {len(missing_movie_nfo_files)} Movie NFO files missing dateadded elements")
|
|
|
|
print(f"🎬 Processing {len(missing_movie_nfo_files)} movie NFO files for detailed information...")
|
|
for i, nfo_file in enumerate(missing_movie_nfo_files):
|
|
print(f" 📁 Movie {i+1}/{len(missing_movie_nfo_files)}: {nfo_file}")
|
|
try:
|
|
# Extract movie info from file path
|
|
# Example: /media/Movies/movies/Knives Out (2019) [imdb-tt8946378]/movie.nfo
|
|
movie_dir = os.path.dirname(nfo_file)
|
|
movie_folder_name = os.path.basename(movie_dir)
|
|
|
|
# Extract IMDb ID from NFO content
|
|
imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=False, verbose_logging=False)
|
|
|
|
# Clean movie title (remove year and imdb parts)
|
|
clean_movie_title = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', movie_folder_name).strip()
|
|
|
|
# Log detailed movie information
|
|
if imdb_id != "unknown":
|
|
print(f"✅ Found IMDb {imdb_id} for movie: {clean_movie_title} ({movie_folder_name})")
|
|
else:
|
|
print(f"❌ No IMDb ID found for movie: {clean_movie_title} ({movie_folder_name})")
|
|
print(f" 📁 Path: {movie_dir}")
|
|
print(f" 📄 NFO: {os.path.basename(nfo_file)}")
|
|
|
|
missing_items["movies"].append({
|
|
"imdb_id": imdb_id,
|
|
"title": clean_movie_title,
|
|
"path": movie_dir,
|
|
"dateadded": None,
|
|
"nfo_path": nfo_file,
|
|
"reason": "NFO missing dateadded element (filesystem scan)"
|
|
})
|
|
except Exception as e:
|
|
print(f"⚠️ Error parsing movie NFO file path {nfo_file}: {e}")
|
|
continue
|
|
|
|
# Database lookup to get actual dateadded values for missing items
|
|
print("🔍 Looking up dateadded values from database for missing items...")
|
|
|
|
# Enhance episodes with database data
|
|
episodes_with_dates = 0
|
|
episodes_missing_db = 0
|
|
|
|
for episode in missing_items["episodes"]:
|
|
if episode["imdb_id"] != "unknown":
|
|
try:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT dateadded, source
|
|
FROM episodes
|
|
WHERE imdb_id = %s AND season = %s AND episode = %s
|
|
LIMIT 1
|
|
""", (episode["imdb_id"], episode["season"], episode["episode"]))
|
|
|
|
result = cursor.fetchone()
|
|
if result:
|
|
episode["dateadded"] = result["dateadded"]
|
|
episode["source"] = result.get("source", "database")
|
|
episodes_with_dates += 1
|
|
print(f"✅ Found database date for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}: {result['dateadded']}")
|
|
else:
|
|
episodes_missing_db += 1
|
|
# Try to find other episodes from same series to estimate date
|
|
cursor.execute("""
|
|
SELECT dateadded, season, episode
|
|
FROM episodes
|
|
WHERE imdb_id = %s AND dateadded IS NOT NULL
|
|
ORDER BY season DESC, episode DESC
|
|
LIMIT 3
|
|
""", (episode["imdb_id"],))
|
|
|
|
similar_episodes = cursor.fetchall()
|
|
if similar_episodes:
|
|
latest_date = similar_episodes[0]["dateadded"]
|
|
episode["dateadded"] = latest_date
|
|
episode["source"] = f"estimated_from_series_latest"
|
|
print(f"🔮 Estimated date for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d} from latest episode: {latest_date}")
|
|
else:
|
|
# Use NFO file modification time as fallback
|
|
try:
|
|
import os
|
|
from datetime import datetime
|
|
if os.path.exists(episode["nfo_path"]):
|
|
file_mtime = os.path.getmtime(episode["nfo_path"])
|
|
file_date = datetime.fromtimestamp(file_mtime)
|
|
episode["dateadded"] = file_date
|
|
episode["source"] = "nfo_file_mtime"
|
|
print(f"📅 Using NFO file date for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}: {file_date}")
|
|
else:
|
|
print(f"❌ No fallback date available for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}")
|
|
except Exception as fallback_error:
|
|
print(f"⚠️ Fallback date lookup failed for {episode['imdb_id']}: {fallback_error}")
|
|
except Exception as e:
|
|
print(f"⚠️ Database lookup failed for episode {episode['imdb_id']}: {e}")
|
|
|
|
# Enhance movies with database data
|
|
movies_with_dates = 0
|
|
movies_missing_db = 0
|
|
|
|
for movie in missing_items["movies"]:
|
|
if movie["imdb_id"] != "unknown":
|
|
try:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT dateadded, source
|
|
FROM movies
|
|
WHERE imdb_id = %s
|
|
LIMIT 1
|
|
""", (movie["imdb_id"],))
|
|
|
|
result = cursor.fetchone()
|
|
if result:
|
|
movie["dateadded"] = result["dateadded"]
|
|
movie["source"] = result.get("source", "database")
|
|
movies_with_dates += 1
|
|
print(f"✅ Found database date for movie {movie['imdb_id']}: {result['dateadded']}")
|
|
else:
|
|
movies_missing_db += 1
|
|
# Use NFO file modification time as fallback for movies
|
|
try:
|
|
import os
|
|
from datetime import datetime
|
|
if os.path.exists(movie["nfo_path"]):
|
|
file_mtime = os.path.getmtime(movie["nfo_path"])
|
|
file_date = datetime.fromtimestamp(file_mtime)
|
|
movie["dateadded"] = file_date
|
|
movie["source"] = "nfo_file_mtime"
|
|
print(f"📅 Using NFO file date for movie {movie['imdb_id']}: {file_date}")
|
|
else:
|
|
print(f"❌ No fallback date available for movie {movie['imdb_id']}")
|
|
except Exception as fallback_error:
|
|
print(f"⚠️ Fallback date lookup failed for movie {movie['imdb_id']}: {fallback_error}")
|
|
except Exception as e:
|
|
print(f"⚠️ Database lookup failed for movie {movie['imdb_id']}: {e}")
|
|
|
|
# Print summary statistics
|
|
total_episodes = len(missing_items["episodes"])
|
|
total_movies = len(missing_items["movies"])
|
|
print(f"📊 Database lookup summary:")
|
|
print(f" Episodes: {episodes_with_dates}/{total_episodes} found in DB, {episodes_missing_db} missing from DB")
|
|
print(f" Movies: {movies_with_dates}/{total_movies} found in DB, {movies_missing_db} missing from DB")
|
|
|
|
# Calculate comprehensive statistics
|
|
total_nfo_files_missing = len(missing_tv_nfo_files) + len(missing_movie_nfo_files)
|
|
total_with_imdb_and_db = len(missing_items["episodes"]) + len(missing_items["movies"])
|
|
|
|
print(f"✅ NFO repair scan complete:")
|
|
print(f" 📄 {total_nfo_files_missing} NFO files missing dateadded elements")
|
|
print(f" 🎬 {len(missing_tv_nfo_files)} TV episodes, {len(missing_movie_nfo_files)} movies")
|
|
print(f" 🔍 {total_with_imdb_and_db} items with IMDb IDs found in database (can be fixed)")
|
|
|
|
return {
|
|
"status": "success",
|
|
"total_missing": total_with_imdb_and_db, # Items that can be fixed
|
|
"total_nfo_files_missing": total_nfo_files_missing, # All NFO files missing dateadded
|
|
"episodes_missing": len(missing_items["episodes"]),
|
|
"movies_missing": len(missing_items["movies"]),
|
|
"tv_nfo_files_missing": len(missing_tv_nfo_files),
|
|
"movie_nfo_files_missing": len(missing_movie_nfo_files),
|
|
"total_tv_files_checked": total_tv_files_checked,
|
|
"total_movie_files_checked": total_movie_files_checked,
|
|
"missing_items": missing_items,
|
|
"statistics": {
|
|
"nfo_files_scanned": {
|
|
"tv": total_tv_files_checked,
|
|
"movies": total_movie_files_checked,
|
|
"total": total_tv_files_checked + total_movie_files_checked
|
|
},
|
|
"nfo_files_missing_dateadded": {
|
|
"tv": len(missing_tv_nfo_files),
|
|
"movies": len(missing_movie_nfo_files),
|
|
"total": total_nfo_files_missing
|
|
},
|
|
"items_with_imdb_and_database": {
|
|
"tv": len(missing_items["episodes"]),
|
|
"movies": len(missing_items["movies"]),
|
|
"total": total_with_imdb_and_db
|
|
}
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error during NFO repair scan: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}")
|
|
|
|
@app.get("/admin/nfo-repair-scan")
|
|
async def _nfo_repair_scan():
|
|
return await nfo_repair_scan()
|
|
|
|
async def nfo_repair_fix():
|
|
"""Fix missing dateadded elements in NFO files using database values"""
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime
|
|
|
|
config = dependencies.get("config")
|
|
db = dependencies.get("db")
|
|
nfo_manager = dependencies.get("nfo_manager")
|
|
|
|
if not config or not db:
|
|
raise HTTPException(status_code=500, detail=f"Dependencies not available")
|
|
|
|
print("🔧 Starting NFO repair fix process from core container")
|
|
|
|
# First, run the scan to identify missing items
|
|
scan_result = await nfo_repair_scan()
|
|
if scan_result["status"] != "success":
|
|
return {"status": "error", "message": "Scan failed"}
|
|
|
|
missing_items = scan_result["missing_items"]
|
|
total_episodes = len(missing_items["episodes"])
|
|
total_movies = len(missing_items["movies"])
|
|
|
|
print(f"🔍 Found {total_episodes} episodes and {total_movies} movies to fix")
|
|
|
|
fixed_count = 0
|
|
failed_count = 0
|
|
results = []
|
|
|
|
# Fix episodes
|
|
for episode in missing_items["episodes"]:
|
|
if episode.get("dateadded") and episode.get("imdb_id") != "unknown":
|
|
try:
|
|
nfo_path = episode["nfo_path"]
|
|
dateadded_value = episode["dateadded"]
|
|
|
|
# Format dateadded for NFO file
|
|
if isinstance(dateadded_value, str):
|
|
# Parse string datetime
|
|
dt = datetime.fromisoformat(dateadded_value.replace('Z', '+00:00'))
|
|
else:
|
|
dt = dateadded_value
|
|
|
|
# Format as NFO expects: 2024-10-15 12:34:56
|
|
nfo_dateadded = dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
# Read and modify NFO file
|
|
if os.path.exists(nfo_path):
|
|
tree = ET.parse(nfo_path)
|
|
root = tree.getroot()
|
|
|
|
# Remove existing dateadded element if present
|
|
existing = root.find("dateadded")
|
|
if existing is not None:
|
|
root.remove(existing)
|
|
|
|
# Add new dateadded element
|
|
dateadded_elem = ET.SubElement(root, "dateadded")
|
|
dateadded_elem.text = nfo_dateadded
|
|
|
|
# Add source attribution (similar to what NFOGuard normally adds)
|
|
# Check if source element exists, if not create it
|
|
source_elem = root.find("source")
|
|
if source_elem is None:
|
|
source_elem = ET.SubElement(root, "source")
|
|
source_elem.text = f"NFOGuard repair - {episode.get('source', 'database')}"
|
|
|
|
# Add addedby attribution if not present
|
|
addedby_elem = root.find("addedby")
|
|
if addedby_elem is None:
|
|
addedby_elem = ET.SubElement(root, "addedby")
|
|
addedby_elem.text = "NFOGuard"
|
|
|
|
# Write back to file
|
|
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
|
|
|
# VERIFY the write was successful by re-reading the file
|
|
try:
|
|
verify_tree = ET.parse(nfo_path)
|
|
verify_root = verify_tree.getroot()
|
|
verify_dateadded = verify_root.find("dateadded")
|
|
|
|
if verify_dateadded is not None and verify_dateadded.text and verify_dateadded.text.strip():
|
|
# Verification successful
|
|
fixed_count += 1
|
|
results.append({
|
|
"type": "episode",
|
|
"imdb_id": episode["imdb_id"],
|
|
"season": episode["season"],
|
|
"episode": episode["episode"],
|
|
"nfo_path": nfo_path,
|
|
"dateadded": nfo_dateadded,
|
|
"verified_dateadded": verify_dateadded.text.strip(),
|
|
"status": "fixed_and_verified"
|
|
})
|
|
print(f"✅ Fixed and verified episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}")
|
|
else:
|
|
# Verification failed - dateadded missing after write
|
|
failed_count += 1
|
|
results.append({
|
|
"type": "episode",
|
|
"imdb_id": episode["imdb_id"],
|
|
"season": episode["season"],
|
|
"episode": episode["episode"],
|
|
"nfo_path": nfo_path,
|
|
"dateadded": nfo_dateadded,
|
|
"status": "write_failed_verification"
|
|
})
|
|
print(f"❌ Write verification FAILED for episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d} - dateadded not found after write")
|
|
except Exception as verify_error:
|
|
# Verification failed due to parse error
|
|
failed_count += 1
|
|
results.append({
|
|
"type": "episode",
|
|
"imdb_id": episode["imdb_id"],
|
|
"season": episode["season"],
|
|
"episode": episode["episode"],
|
|
"nfo_path": nfo_path,
|
|
"status": "verification_parse_error",
|
|
"error": str(verify_error)
|
|
})
|
|
print(f"❌ Verification parse error for episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}: {verify_error}")
|
|
else:
|
|
failed_count += 1
|
|
print(f"❌ NFO file not found: {nfo_path}")
|
|
|
|
except Exception as e:
|
|
failed_count += 1
|
|
print(f"❌ Failed to fix episode {episode.get('imdb_id', 'unknown')}: {e}")
|
|
|
|
# Fix movies
|
|
for movie in missing_items["movies"]:
|
|
if movie.get("dateadded") and movie.get("imdb_id") != "unknown":
|
|
try:
|
|
nfo_path = movie["nfo_path"]
|
|
dateadded_value = movie["dateadded"]
|
|
|
|
# Format dateadded for NFO file
|
|
if isinstance(dateadded_value, str):
|
|
dt = datetime.fromisoformat(dateadded_value.replace('Z', '+00:00'))
|
|
else:
|
|
dt = dateadded_value
|
|
|
|
nfo_dateadded = dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
# Read and modify NFO file
|
|
if os.path.exists(nfo_path):
|
|
tree = ET.parse(nfo_path)
|
|
root = tree.getroot()
|
|
|
|
# Remove existing dateadded element if present
|
|
existing = root.find("dateadded")
|
|
if existing is not None:
|
|
root.remove(existing)
|
|
|
|
# Add new dateadded element
|
|
dateadded_elem = ET.SubElement(root, "dateadded")
|
|
dateadded_elem.text = nfo_dateadded
|
|
|
|
# Add source attribution (similar to what NFOGuard normally adds)
|
|
source_elem = root.find("source")
|
|
if source_elem is None:
|
|
source_elem = ET.SubElement(root, "source")
|
|
source_elem.text = f"NFOGuard repair - {movie.get('source', 'database')}"
|
|
|
|
# Add addedby attribution if not present
|
|
addedby_elem = root.find("addedby")
|
|
if addedby_elem is None:
|
|
addedby_elem = ET.SubElement(root, "addedby")
|
|
addedby_elem.text = "NFOGuard"
|
|
|
|
# Write back to file
|
|
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
|
|
|
# VERIFY the write was successful by re-reading the file
|
|
try:
|
|
verify_tree = ET.parse(nfo_path)
|
|
verify_root = verify_tree.getroot()
|
|
verify_dateadded = verify_root.find("dateadded")
|
|
|
|
if verify_dateadded is not None and verify_dateadded.text and verify_dateadded.text.strip():
|
|
# Verification successful
|
|
fixed_count += 1
|
|
results.append({
|
|
"type": "movie",
|
|
"imdb_id": movie["imdb_id"],
|
|
"title": movie["title"],
|
|
"nfo_path": nfo_path,
|
|
"dateadded": nfo_dateadded,
|
|
"verified_dateadded": verify_dateadded.text.strip(),
|
|
"status": "fixed_and_verified"
|
|
})
|
|
print(f"✅ Fixed and verified movie {movie['imdb_id']} ({movie['title']})")
|
|
else:
|
|
# Verification failed - dateadded missing after write
|
|
failed_count += 1
|
|
results.append({
|
|
"type": "movie",
|
|
"imdb_id": movie["imdb_id"],
|
|
"title": movie["title"],
|
|
"nfo_path": nfo_path,
|
|
"dateadded": nfo_dateadded,
|
|
"status": "write_failed_verification"
|
|
})
|
|
print(f"❌ Write verification FAILED for movie {movie['imdb_id']} ({movie['title']}) - dateadded not found after write")
|
|
except Exception as verify_error:
|
|
# Verification failed due to parse error
|
|
failed_count += 1
|
|
results.append({
|
|
"type": "movie",
|
|
"imdb_id": movie["imdb_id"],
|
|
"title": movie["title"],
|
|
"nfo_path": nfo_path,
|
|
"status": "verification_parse_error",
|
|
"error": str(verify_error)
|
|
})
|
|
print(f"❌ Verification parse error for movie {movie['imdb_id']} ({movie['title']}): {verify_error}")
|
|
else:
|
|
failed_count += 1
|
|
print(f"❌ NFO file not found: {nfo_path}")
|
|
|
|
except Exception as e:
|
|
failed_count += 1
|
|
print(f"❌ Failed to fix movie {movie.get('imdb_id', 'unknown')}: {e}")
|
|
|
|
print(f"✅ NFO repair fix complete: {fixed_count} fixed, {failed_count} failed")
|
|
|
|
# Final summary with detailed statistics
|
|
verification_summary = {
|
|
"fixed_and_verified": sum(1 for r in results if r.get("status") == "fixed_and_verified"),
|
|
"write_failed_verification": sum(1 for r in results if r.get("status") == "write_failed_verification"),
|
|
"verification_parse_error": sum(1 for r in results if r.get("status") == "verification_parse_error")
|
|
}
|
|
|
|
print(f"📊 Final summary: {fixed_count} fixed, {failed_count} failed")
|
|
print(f" ✅ Verified successful: {verification_summary['fixed_and_verified']}")
|
|
print(f" ❌ Write verification failed: {verification_summary['write_failed_verification']}")
|
|
print(f" ⚠️ Verification parse errors: {verification_summary['verification_parse_error']}")
|
|
|
|
return {
|
|
"status": "success",
|
|
"total_processed": total_episodes + total_movies,
|
|
"fixed_count": fixed_count,
|
|
"failed_count": failed_count,
|
|
"verification_summary": verification_summary,
|
|
"results": results[:20] # Show first 20 for UI
|
|
}
|
|
|
|
@app.post("/admin/nfo-repair-fix")
|
|
async def _nfo_repair_fix():
|
|
return await nfo_repair_fix()
|
|
|
|
@app.get("/admin/missing-imdb")
|
|
async def get_missing_imdb():
|
|
"""Get items missing IMDb IDs for manual review"""
|
|
config = dependencies.get("config")
|
|
db = dependencies.get("db")
|
|
|
|
if not config or not db:
|
|
raise HTTPException(status_code=500, detail="Dependencies not available")
|
|
|
|
print("📋 Retrieving missing IMDb items from core container")
|
|
|
|
try:
|
|
# Get missing items by type
|
|
missing_tv = db.get_missing_imdb_items(media_type="tv", resolved=False)
|
|
missing_movies = db.get_missing_imdb_items(media_type="movie", resolved=False)
|
|
|
|
# Format for web interface
|
|
missing_items = []
|
|
|
|
for item in missing_tv:
|
|
missing_items.append({
|
|
"id": item["id"],
|
|
"type": "TV Series",
|
|
"folder_name": item["folder_name"],
|
|
"file_path": item["file_path"],
|
|
"discovered_at": item["discovered_at"].isoformat() if item["discovered_at"] else None,
|
|
"last_checked": item["last_checked"].isoformat() if item["last_checked"] else None,
|
|
"check_count": item["check_count"],
|
|
"notes": item["notes"]
|
|
})
|
|
|
|
for item in missing_movies:
|
|
missing_items.append({
|
|
"id": item["id"],
|
|
"type": "Movie",
|
|
"folder_name": item["folder_name"],
|
|
"file_path": item["file_path"],
|
|
"discovered_at": item["discovered_at"].isoformat() if item["discovered_at"] else None,
|
|
"last_checked": item["last_checked"].isoformat() if item["last_checked"] else None,
|
|
"check_count": item["check_count"],
|
|
"notes": item["notes"]
|
|
})
|
|
|
|
print(f"✅ Found {len(missing_tv)} TV series and {len(missing_movies)} movies missing IMDb IDs")
|
|
|
|
return {
|
|
"status": "success",
|
|
"missing_items": missing_items,
|
|
"summary": {
|
|
"total_missing": len(missing_items),
|
|
"tv_series": len(missing_tv),
|
|
"movies": len(missing_movies)
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error retrieving missing IMDb items: {e}")
|
|
return {
|
|
"status": "error",
|
|
"message": f"Failed to retrieve missing IMDb items: {str(e)}"
|
|
}
|
|
|
|
# ---------------------------
|
|
# Core API - No Web Interface
|
|
# ---------------------------
|
|
|
|
@app.get("/")
|
|
async def _core_info():
|
|
"""Core container API information - Web interface on separate container"""
|
|
import os
|
|
# Get configured web port from environment or config
|
|
web_port = os.environ.get("WEB_EXTERNAL_PORT",
|
|
getattr(dependencies.get("config", None), "web_api_port", "8081"))
|
|
|
|
return {
|
|
"service": "NFOGuard Core Processing Engine",
|
|
"version": "2.7.0",
|
|
"message": f"Web interface available on separate container (port {web_port})",
|
|
"api_endpoints": {
|
|
"health": "/health",
|
|
"webhooks": "/webhook/*",
|
|
"manual_scans": "/manual/*",
|
|
"database": "/database/*",
|
|
"api_docs": "/docs"
|
|
}
|
|
} |