Compare commits
6 Commits
dev
..
8dbc140b97
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dbc140b97 | |||
| 03bcd44565 | |||
| 959bdba71c | |||
| 6dc3dfd9ef | |||
| 6673ef8465 | |||
| 9072c86779 |
+8
-27
@@ -70,21 +70,6 @@ RADARR_DB_PORT=5432
|
||||
RADARR_DB_NAME=radarr-main
|
||||
RADARR_DB_USER=postgres
|
||||
|
||||
# ===========================================
|
||||
# SONARR DATABASE CONNECTION (RECOMMENDED)
|
||||
# ===========================================
|
||||
# Direct database access for better performance
|
||||
# Phase 7: High-performance TV episode import date detection
|
||||
SONARR_DB_TYPE=postgresql
|
||||
SONARR_DB_HOST=192.168.255.50
|
||||
SONARR_DB_PORT=5432
|
||||
SONARR_DB_NAME=sonarr-main
|
||||
SONARR_DB_USER=postgres
|
||||
|
||||
# Alternative: SQLite (if Sonarr uses SQLite)
|
||||
# SONARR_DB_TYPE=sqlite
|
||||
# SONARR_DB_PATH=/path/to/sonarr.db
|
||||
|
||||
# ===========================================
|
||||
# API CONNECTIONS (OPTIONAL)
|
||||
# ===========================================
|
||||
@@ -112,22 +97,18 @@ ALLOW_FILE_DATE_FALLBACK=false
|
||||
TMDB_COUNTRY=US
|
||||
|
||||
# ===========================================
|
||||
# NFO FILE MANAGEMENT (DEPRECATED - Phase 1 Migration)
|
||||
# NFO FILE MANAGEMENT
|
||||
# ===========================================
|
||||
# NFO file operations have been removed in favor of database-only architecture
|
||||
# These settings are preserved for backward compatibility but no longer have effect
|
||||
# The PostgreSQL database is now the single source of truth for all metadata
|
||||
# Create/update .nfo files
|
||||
MANAGE_NFO=true
|
||||
|
||||
# Create/update .nfo files (DEPRECATED - no longer used)
|
||||
MANAGE_NFO=false
|
||||
# Update file modification times to match import dates
|
||||
FIX_DIR_MTIMES=true
|
||||
|
||||
# Update file modification times to match import dates (DEPRECATED - no longer used)
|
||||
FIX_DIR_MTIMES=false
|
||||
# Add lockdata tags to prevent metadata overwrites
|
||||
LOCK_METADATA=true
|
||||
|
||||
# Add lockdata tags to prevent metadata overwrites (DEPRECATED - no longer used)
|
||||
LOCK_METADATA=false
|
||||
|
||||
# Brand name in NFO comments (DEPRECATED - no longer used)
|
||||
# Brand name in NFO comments
|
||||
MANAGER_BRAND=NFOGuard
|
||||
|
||||
# ===========================================
|
||||
|
||||
@@ -120,65 +120,3 @@ class EpisodeResponse(BaseModel):
|
||||
series_path: str
|
||||
season_name: str
|
||||
episode_name: str
|
||||
|
||||
|
||||
# Scheduled Scans Models
|
||||
|
||||
class CreateScheduledScanRequest(BaseModel):
|
||||
"""Request model for creating a scheduled scan"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
cron_expression: str
|
||||
media_type: str # 'tv', 'movies', 'both'
|
||||
scan_mode: str # 'smart', 'full', 'incomplete'
|
||||
specific_paths: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UpdateScheduledScanRequest(BaseModel):
|
||||
"""Request model for updating a scheduled scan"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
media_type: Optional[str] = None
|
||||
scan_mode: Optional[str] = None
|
||||
specific_paths: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ScheduledScanResponse(BaseModel):
|
||||
"""Response model for scheduled scan data"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
cron_expression: str
|
||||
media_type: str
|
||||
scan_mode: str
|
||||
specific_paths: Optional[str]
|
||||
enabled: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_run_at: Optional[str]
|
||||
next_run_at: Optional[str]
|
||||
run_count: int
|
||||
created_by: Optional[str]
|
||||
updated_by: Optional[str]
|
||||
|
||||
|
||||
class ScheduleExecutionResponse(BaseModel):
|
||||
"""Response model for schedule execution data"""
|
||||
id: int
|
||||
schedule_id: int
|
||||
schedule_name: str
|
||||
started_at: str
|
||||
completed_at: Optional[str]
|
||||
status: str
|
||||
media_type: str
|
||||
scan_mode: str
|
||||
items_processed: int
|
||||
items_skipped: int
|
||||
items_failed: int
|
||||
execution_time_seconds: Optional[int]
|
||||
error_message: Optional[str]
|
||||
logs: Optional[str]
|
||||
triggered_by: Optional[str]
|
||||
+38
-534
@@ -15,8 +15,6 @@ from api.models import (
|
||||
SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
|
||||
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
|
||||
)
|
||||
# Import logging utility
|
||||
from utils.logging import _log
|
||||
# Web routes removed - handled by separate web container
|
||||
|
||||
# Global scan status tracking for detailed progress
|
||||
@@ -52,7 +50,7 @@ async def _read_payload(request: Request) -> dict:
|
||||
return json.loads(form["payload"])
|
||||
return dict(form)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to read webhook payload: {e}")
|
||||
print(f"ERROR: Failed to read webhook payload: {e}") # Using print since _log is not available
|
||||
return {}
|
||||
|
||||
|
||||
@@ -72,7 +70,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
raise HTTPException(status_code=422, detail="Empty Sonarr payload")
|
||||
|
||||
webhook = SonarrWebhook(**payload)
|
||||
_log("INFO", f"Received Sonarr webhook: {webhook.eventType}")
|
||||
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"}
|
||||
@@ -88,7 +86,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
sonarr_path = series_info.get("path", "")
|
||||
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID for series: {series_title}")
|
||||
print(f"ERROR: No IMDb ID for series: {series_title}")
|
||||
return {"status": "error", "reason": "No IMDb ID"}
|
||||
|
||||
# Find series path
|
||||
@@ -99,7 +97,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
|
||||
# Extract episode data for targeted processing
|
||||
episodes_data = webhook.episodes or []
|
||||
_log("DEBUG", f"Initial episodes_data from webhook.episodes: {len(episodes_data)} episodes")
|
||||
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)
|
||||
@@ -139,7 +137,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
"title": episode_file.get("title")
|
||||
# Note: Not including dateAdded - we use database-first approach with Sonarr fallback
|
||||
}]
|
||||
_log("INFO", f"Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}")
|
||||
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}")
|
||||
|
||||
@@ -243,7 +241,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
processing_mode = config.tv_webhook_processing_mode
|
||||
if episodes_data and len(episodes_data) <= 3: # Single episode or small batch
|
||||
processing_mode = "targeted"
|
||||
_log("INFO", f"Forcing targeted mode for {len(episodes_data)} episode(s)")
|
||||
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}"
|
||||
@@ -259,7 +257,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Sonarr webhook error: {e}")
|
||||
print(f"ERROR: Sonarr webhook error: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
|
||||
|
||||
|
||||
@@ -270,8 +268,8 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
|
||||
try:
|
||||
payload = await _read_payload(request)
|
||||
_log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
|
||||
_log("DEBUG", f"Full Radarr webhook payload: {payload}")
|
||||
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', '')
|
||||
@@ -281,29 +279,29 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
# Extract movie info
|
||||
movie_data = payload.get("movie", {})
|
||||
if not movie_data:
|
||||
_log("WARNING", "No movie data in Radarr webhook")
|
||||
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:
|
||||
_log("WARNING", "No IMDb ID in Radarr webhook movie data")
|
||||
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:
|
||||
_log("ERROR", "No movie path in Radarr webhook")
|
||||
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)
|
||||
_log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}")
|
||||
print(f"DEBUG: Mapped Radarr path {movie_path} -> {container_path}")
|
||||
|
||||
# CRITICAL: Verify the mapped path actually exists
|
||||
if not Path(container_path).exists():
|
||||
_log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
|
||||
_log("ERROR", "This prevents processing wrong movies due to path mapping issues")
|
||||
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
|
||||
@@ -320,13 +318,13 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
||||
|
||||
# Add to batch queue with movie-prefixed key to avoid TV conflicts
|
||||
movie_batch_key = f"movie:{imdb_id}"
|
||||
_log("DEBUG", f"Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}")
|
||||
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:
|
||||
_log("ERROR", f"Radarr webhook error: {e}")
|
||||
print(f"ERROR: Radarr webhook error: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@@ -341,8 +339,8 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
raise HTTPException(status_code=422, detail="Empty Maintainarr payload")
|
||||
|
||||
webhook = MaintainarrWebhook(**payload)
|
||||
_log("INFO", f"Received Maintainarr webhook: {webhook.notification_type}")
|
||||
_log("DEBUG", f"Full Maintainarr webhook payload: {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 ""
|
||||
@@ -387,7 +385,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
break
|
||||
|
||||
if not imdb_id:
|
||||
_log("WARNING", f"No IMDb ID found in Maintainarr webhook - Message: '{message}', Subject: '{subject}'")
|
||||
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
|
||||
@@ -416,7 +414,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
elif movie:
|
||||
media_type = "Movie"
|
||||
else:
|
||||
_log("INFO", f"Media {title} ({imdb_id}) not found in database")
|
||||
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
|
||||
@@ -424,7 +422,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
removed_items = []
|
||||
|
||||
if media_type == "Movie":
|
||||
_log("INFO", f"Processing movie deletion for {title} ({imdb_id})")
|
||||
print(f"INFO: Processing movie deletion for {title} ({imdb_id})")
|
||||
|
||||
# Check if movie exists in database
|
||||
movie = db.get_movie_by_imdb(imdb_id)
|
||||
@@ -432,14 +430,14 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
if db.delete_movie(imdb_id):
|
||||
removed_count += 1
|
||||
removed_items.append(f"Movie: {title} ({imdb_id})")
|
||||
_log("INFO", f"SUCCESS: Removed movie {title} ({imdb_id}) from database")
|
||||
print(f"SUCCESS: Removed movie {title} ({imdb_id}) from database")
|
||||
else:
|
||||
_log("WARNING", f"Failed to remove movie {title} ({imdb_id}) from database")
|
||||
print(f"WARNING: Failed to remove movie {title} ({imdb_id}) from database")
|
||||
else:
|
||||
_log("INFO", f"Movie {title} ({imdb_id}) not found in database")
|
||||
print(f"INFO: Movie {title} ({imdb_id}) not found in database")
|
||||
|
||||
elif media_type == "Series":
|
||||
_log("INFO", f"Processing series deletion for {title} ({imdb_id})")
|
||||
print(f"INFO: Processing series deletion for {title} ({imdb_id})")
|
||||
|
||||
# Check if series exists in database
|
||||
series = db.get_series_by_imdb(imdb_id)
|
||||
@@ -455,11 +453,11 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
if db.delete_series(imdb_id):
|
||||
removed_count += 1
|
||||
removed_items.append(f"Series: {title} ({imdb_id})")
|
||||
_log("INFO", f"SUCCESS: Removed series {title} ({imdb_id}) from database")
|
||||
print(f"SUCCESS: Removed series {title} ({imdb_id}) from database")
|
||||
else:
|
||||
_log("WARNING", f"Failed to remove series {title} ({imdb_id}) from database")
|
||||
print(f"WARNING: Failed to remove series {title} ({imdb_id}) from database")
|
||||
else:
|
||||
_log("INFO", f"Series {title} ({imdb_id}) not found in database")
|
||||
print(f"INFO: Series {title} ({imdb_id}) not found in database")
|
||||
|
||||
# Log the cleanup operation
|
||||
if removed_count > 0:
|
||||
@@ -483,9 +481,9 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Maintainarr webhook error: {e}")
|
||||
print(f"ERROR: Maintainarr webhook error: {e}")
|
||||
import traceback
|
||||
_log("ERROR", f"Traceback: {traceback.format_exc()}")
|
||||
print(f"ERROR: Traceback: {traceback.format_exc()}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@@ -497,7 +495,7 @@ async def _log_maintainarr_cleanup(event_type: str, media_type: str, title: str,
|
||||
log_message += f" from collection '{collection_name}'"
|
||||
log_message += f". Removed from database: {', '.join(removed_items)}"
|
||||
|
||||
_log("INFO", log_message)
|
||||
print(f"INFO: {log_message}")
|
||||
|
||||
# Could extend this to write to a cleanup log file or database table
|
||||
|
||||
@@ -788,27 +786,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
movie_skipped = 0
|
||||
movie_processed = 0
|
||||
|
||||
def translate_host_path_to_container(path_str):
|
||||
"""Translate host paths to container paths for Docker volume mounts"""
|
||||
path_str = str(path_str)
|
||||
# Handle the common Docker volume mount: /mnt/unionfs/Media/ -> /media/
|
||||
if path_str.startswith('/mnt/unionfs/Media/'):
|
||||
container_path = path_str.replace('/mnt/unionfs/Media/', '/media/')
|
||||
print(f"DEBUG: Translated host path '{path_str}' to container path '{container_path}'")
|
||||
return container_path
|
||||
# Handle case where user enters /Media/ instead of /media/ (case sensitivity)
|
||||
elif path_str.startswith('/Media/'):
|
||||
container_path = path_str.replace('/Media/', '/media/')
|
||||
print(f"DEBUG: Fixed case sensitivity '{path_str}' to container path '{container_path}'")
|
||||
return container_path
|
||||
return path_str
|
||||
|
||||
paths_to_scan = []
|
||||
if path:
|
||||
# Translate the provided path from host format to container format
|
||||
translated_path = translate_host_path_to_container(path)
|
||||
paths_to_scan = [Path(translated_path)]
|
||||
print(f"DEBUG: Manual scan with specific path: {path} -> {translated_path}")
|
||||
paths_to_scan = [Path(path)]
|
||||
else:
|
||||
if scan_type in ["both", "tv"]:
|
||||
paths_to_scan.extend(config.tv_paths)
|
||||
@@ -816,17 +796,12 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
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")
|
||||
@@ -839,7 +814,6 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
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
|
||||
@@ -853,19 +827,15 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
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:
|
||||
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client, shutdown_event):
|
||||
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}, scan_mode={scan_mode}")
|
||||
result = tv_processor.process_series(scan_path, force_scan=force_scan, scan_mode=scan_mode)
|
||||
result = tv_processor.process_series(scan_path, force_scan=force_scan)
|
||||
tv_series_total += 1
|
||||
if result == "skipped":
|
||||
tv_series_skipped += 1
|
||||
@@ -930,7 +900,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
try:
|
||||
# Determine force_scan based on scan mode
|
||||
force_scan = (scan_mode == "full")
|
||||
result = tv_processor.process_series(item, force_scan=force_scan, scan_mode=scan_mode)
|
||||
result = tv_processor.process_series(item, force_scan=force_scan)
|
||||
tv_series_total += 1
|
||||
if result == "skipped":
|
||||
tv_series_skipped += 1
|
||||
@@ -1003,7 +973,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
# 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, scan_mode=scan_mode, shutdown_event=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
|
||||
@@ -2200,7 +2170,7 @@ async def update_episode_nfo(imdb_id: str, season: int, episode: int, request: R
|
||||
return {"success": False, "message": f"Series directory not found for {imdb_id}"}
|
||||
|
||||
# Get season directory
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
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}"}
|
||||
|
||||
@@ -2223,430 +2193,6 @@ async def update_episode_nfo(imdb_id: str, season: int, episode: int, request: R
|
||||
return {"success": False, "message": f"Failed to update NFO file: {str(e)}"}
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Emby Plugin Lookup Functions
|
||||
# ---------------------------
|
||||
|
||||
async def lookup_episode(imdb_id: str, season: int, episode: int, dependencies: dict):
|
||||
"""
|
||||
Lookup episode dateadded from NFOGuard database for Emby plugin integration
|
||||
|
||||
Returns dateadded information if found, or null if not available.
|
||||
Used by Emby plugin to populate missing dateadded elements in NFO files.
|
||||
"""
|
||||
try:
|
||||
print(f"DEBUG: Episode lookup called for {imdb_id} S{season:02d}E{episode:02d}")
|
||||
|
||||
db = dependencies.get("db")
|
||||
if not db:
|
||||
print(f"ERROR: Database not available in dependencies")
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
# Normalize IMDb ID (ensure tt prefix)
|
||||
if not imdb_id.startswith('tt'):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
print(f"DEBUG: Querying database for episode {imdb_id} S{season:02d}E{episode:02d}")
|
||||
|
||||
# Query database for episode
|
||||
result = db.get_episode_date(imdb_id, season, episode)
|
||||
|
||||
print(f"DEBUG: Database query result: {result}")
|
||||
|
||||
if result and result.get('dateadded'):
|
||||
# Format response for Emby plugin
|
||||
dateadded = result['dateadded']
|
||||
|
||||
# Convert datetime to ISO string if needed
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded_str = dateadded.isoformat()
|
||||
else:
|
||||
dateadded_str = str(dateadded)
|
||||
|
||||
# NOTE: Auto-fix functionality removed - just return database data
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"imdb_id": imdb_id,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"dateadded": dateadded_str,
|
||||
"source": result.get('source', 'database'),
|
||||
"air_date": result.get('air_date') if result.get('air_date') else None,
|
||||
"auto_fixed": False # Auto-fix functionality disabled
|
||||
}
|
||||
else:
|
||||
# Not found in database
|
||||
return {
|
||||
"found": False,
|
||||
"imdb_id": imdb_id,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"dateadded": None,
|
||||
"source": None,
|
||||
"air_date": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Episode lookup failed for {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Episode lookup failed: {str(e)}")
|
||||
|
||||
|
||||
def extract_title_and_year_from_path(nfo_path: str) -> tuple:
|
||||
"""
|
||||
Extract movie title and year from NFO file path
|
||||
Examples:
|
||||
- "/path/Scream (1996)/movie.nfo" -> ("Scream", "1996")
|
||||
- "/path/Witchboard (2025)/movie.nfo" -> ("Witchboard", "2025")
|
||||
- "/path/The Conjuring 2 (2016) [tt3065204]/movie.nfo" -> ("The Conjuring 2", "2016")
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Get the directory name (movie folder)
|
||||
dir_name = Path(nfo_path).parent.name
|
||||
|
||||
# Pattern to extract title and year: "Title (YYYY)"
|
||||
pattern = r'^(.+?)\s*\((\d{4})\)'
|
||||
match = re.search(pattern, dir_name)
|
||||
|
||||
if match:
|
||||
title = match.group(1).strip()
|
||||
year = match.group(2)
|
||||
return title, year
|
||||
|
||||
# Fallback: try to find year anywhere in the path
|
||||
year_pattern = r'\((\d{4})\)'
|
||||
year_match = re.search(year_pattern, dir_name)
|
||||
year = year_match.group(1) if year_match else None
|
||||
|
||||
# Remove any [imdb-*] or [tt*] patterns and year patterns
|
||||
clean_title = re.sub(r'\s*\[\s*(?:imdb-)?tt\d+\s*\]', '', dir_name)
|
||||
clean_title = re.sub(r'\s*\(\d{4}\)', '', clean_title)
|
||||
clean_title = clean_title.strip()
|
||||
|
||||
return clean_title, year
|
||||
|
||||
|
||||
async def lookup_movie_comprehensive(nfo_path: str, dependencies: dict):
|
||||
"""
|
||||
Comprehensive movie lookup that tries multiple methods:
|
||||
1. Extract IMDb ID from path/NFO -> lookup by IMDb
|
||||
2. Extract title/year from path -> lookup by title
|
||||
3. Extract title only -> fuzzy lookup
|
||||
"""
|
||||
try:
|
||||
_log("DEBUG", f"Comprehensive movie lookup for: {nfo_path}")
|
||||
|
||||
# First try to extract IMDb ID from path
|
||||
from utils.nfo_patterns import extract_imdb_id_from_text
|
||||
imdb_id = extract_imdb_id_from_text(nfo_path)
|
||||
|
||||
if imdb_id:
|
||||
_log("DEBUG", f"Found IMDb ID in path: {imdb_id}")
|
||||
result = await lookup_movie(imdb_id, dependencies)
|
||||
if result.get("found"):
|
||||
result["lookup_method"] = "imdb_from_path"
|
||||
return result
|
||||
|
||||
# If IMDb lookup failed, try title-based lookup
|
||||
title, year = extract_title_and_year_from_path(nfo_path)
|
||||
_log("DEBUG", f"Extracted title: '{title}', year: '{year}' from path")
|
||||
|
||||
if title:
|
||||
result = await lookup_movie_by_title(title, year, dependencies)
|
||||
if result.get("found"):
|
||||
return result
|
||||
|
||||
# All methods failed
|
||||
return {
|
||||
"found": False,
|
||||
"nfo_path": nfo_path,
|
||||
"extracted_title": title,
|
||||
"extracted_year": year,
|
||||
"extracted_imdb": imdb_id,
|
||||
"lookup_method": "comprehensive_failed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Comprehensive movie lookup failed for {nfo_path}: {e}")
|
||||
return {
|
||||
"found": False,
|
||||
"error": str(e),
|
||||
"lookup_method": "comprehensive_error"
|
||||
}
|
||||
|
||||
|
||||
async def lookup_movie_by_title(title: str, year: str, dependencies: dict):
|
||||
"""
|
||||
Lookup movie by title and year when IMDb ID is not available
|
||||
|
||||
This is a fallback for when Radarr overwrites NFO files and IMDb IDs are lost.
|
||||
"""
|
||||
try:
|
||||
db = dependencies.get("db")
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
_log("DEBUG", f"Movie lookup by title: '{title}' year: '{year}'")
|
||||
|
||||
# Clean up the title (remove common brackets, extra spaces)
|
||||
clean_title = title.strip()
|
||||
|
||||
# Search database by title pattern matching
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try exact title match first
|
||||
if year:
|
||||
cursor.execute("""
|
||||
SELECT * FROM movies
|
||||
WHERE LOWER(title) = LOWER(%s) AND released::text LIKE %s
|
||||
ORDER BY dateadded DESC
|
||||
LIMIT 1
|
||||
""", (clean_title, f"{year}%"))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT * FROM movies
|
||||
WHERE LOWER(title) = LOWER(%s)
|
||||
ORDER BY dateadded DESC
|
||||
LIMIT 1
|
||||
""", (clean_title,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
# If exact match fails, try fuzzy matching
|
||||
if not row:
|
||||
_log("DEBUG", f"Exact match failed, trying fuzzy match for '{clean_title}'")
|
||||
|
||||
if year:
|
||||
cursor.execute("""
|
||||
SELECT * FROM movies
|
||||
WHERE LOWER(title) LIKE LOWER(%s) AND released::text LIKE %s
|
||||
ORDER BY dateadded DESC
|
||||
LIMIT 1
|
||||
""", (f"%{clean_title}%", f"{year}%"))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT * FROM movies
|
||||
WHERE LOWER(title) LIKE LOWER(%s)
|
||||
ORDER BY dateadded DESC
|
||||
LIMIT 1
|
||||
""", (f"%{clean_title}%",))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
result = dict(row)
|
||||
_log("DEBUG", f"Found movie by title search: {result}")
|
||||
|
||||
if result.get('dateadded'):
|
||||
dateadded = result['dateadded']
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded_str = dateadded.isoformat()
|
||||
else:
|
||||
dateadded_str = str(dateadded)
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"imdb_id": result.get('imdb_id'),
|
||||
"title": result.get('title'),
|
||||
"dateadded": dateadded_str,
|
||||
"source": result.get('source', 'database'),
|
||||
"released": result.get('released') if result.get('released') else None,
|
||||
"lookup_method": "title_search"
|
||||
}
|
||||
|
||||
_log("DEBUG", f"No movie found for title '{clean_title}' year '{year}'")
|
||||
return {
|
||||
"found": False,
|
||||
"title": clean_title,
|
||||
"year": year,
|
||||
"lookup_method": "title_search"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Movie title lookup failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Movie title lookup failed: {str(e)}")
|
||||
|
||||
|
||||
async def lookup_movie(imdb_id: str, dependencies: dict):
|
||||
"""
|
||||
Lookup movie dateadded from NFOGuard database for Emby plugin integration
|
||||
|
||||
Returns dateadded information if found, or null if not available.
|
||||
Used by Emby plugin to populate missing dateadded elements in NFO files.
|
||||
"""
|
||||
try:
|
||||
db = dependencies.get("db")
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database not available")
|
||||
|
||||
_log("DEBUG", f"Movie lookup called for IMDb ID: {imdb_id}")
|
||||
|
||||
# Normalize IMDb ID (ensure tt prefix)
|
||||
if not imdb_id.startswith('tt'):
|
||||
imdb_id = f"tt{imdb_id}"
|
||||
|
||||
_log("DEBUG", f"Normalized IMDb ID: {imdb_id}")
|
||||
|
||||
# Query database for movie
|
||||
result = db.get_movie_dates(imdb_id)
|
||||
_log("DEBUG", f"Movie lookup for {imdb_id}: database result = {result}")
|
||||
|
||||
# If not found, let's see what movies we DO have in the database
|
||||
if not result:
|
||||
_log("DEBUG", f"Movie {imdb_id} not found, checking what movies exist in database...")
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT imdb_id, title FROM movies ORDER BY dateadded DESC LIMIT 10")
|
||||
recent_movies = cursor.fetchall()
|
||||
_log("DEBUG", f"Recent movies in database: {[dict(row) for row in recent_movies]}")
|
||||
|
||||
if result and result.get('dateadded'):
|
||||
# Format response for Emby plugin
|
||||
dateadded = result['dateadded']
|
||||
|
||||
# Convert datetime to ISO string if needed
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded_str = dateadded.isoformat()
|
||||
else:
|
||||
dateadded_str = str(dateadded)
|
||||
|
||||
# NOTE: Auto-fix functionality removed - just return database data
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"imdb_id": imdb_id,
|
||||
"dateadded": dateadded_str,
|
||||
"source": result.get('source', 'database'),
|
||||
"released": result.get('released') if result.get('released') else None,
|
||||
"auto_fixed": False # Auto-fix functionality disabled
|
||||
}
|
||||
else:
|
||||
# Not found in database
|
||||
return {
|
||||
"found": False,
|
||||
"imdb_id": imdb_id,
|
||||
"dateadded": None,
|
||||
"source": None,
|
||||
"released": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Movie lookup failed for {imdb_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Movie lookup failed: {str(e)}")
|
||||
|
||||
|
||||
async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None):
|
||||
"""
|
||||
Populate NFOGuard database from Radarr/Sonarr sources
|
||||
|
||||
Args:
|
||||
background_tasks: FastAPI background tasks
|
||||
media_type: Type of media to populate ("movies", "tv", or "both")
|
||||
dependencies: Dictionary with db, radarr_client, sonarr_client
|
||||
|
||||
Returns:
|
||||
Status message indicating population has started
|
||||
"""
|
||||
from core.database_populator import DatabasePopulator
|
||||
|
||||
db = dependencies["db"]
|
||||
config = dependencies["config"]
|
||||
|
||||
# Get Radarr and Sonarr clients
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.sonarr_client import SonarrClient
|
||||
|
||||
radarr_client = RadarrClient(config)
|
||||
sonarr_client = SonarrClient(config)
|
||||
|
||||
if media_type not in ["both", "movies", "tv"]:
|
||||
raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'")
|
||||
|
||||
# Create global status tracking
|
||||
populate_status = {
|
||||
"running": True,
|
||||
"media_type": media_type,
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"movies": {"status": "pending", "stats": None},
|
||||
"tv": {"status": "pending", "stats": None},
|
||||
"completed": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# Store status globally so it can be queried
|
||||
global _populate_status
|
||||
_populate_status = populate_status
|
||||
|
||||
async def run_population():
|
||||
"""Background task to populate the database"""
|
||||
try:
|
||||
populator = DatabasePopulator(db, radarr_client, sonarr_client)
|
||||
|
||||
_log("INFO", f"Starting database population: {media_type}")
|
||||
|
||||
if media_type == "movies":
|
||||
populate_status["movies"]["status"] = "running"
|
||||
movie_stats = populator.populate_movies()
|
||||
populate_status["movies"]["status"] = "completed"
|
||||
populate_status["movies"]["stats"] = movie_stats
|
||||
_log("INFO", f"Movie population completed: {movie_stats}")
|
||||
|
||||
elif media_type == "tv":
|
||||
populate_status["tv"]["status"] = "running"
|
||||
tv_stats = populator.populate_tv_episodes()
|
||||
populate_status["tv"]["status"] = "completed"
|
||||
populate_status["tv"]["stats"] = tv_stats
|
||||
_log("INFO", f"TV population completed: {tv_stats}")
|
||||
|
||||
elif media_type == "both":
|
||||
populate_status["movies"]["status"] = "running"
|
||||
movie_stats = populator.populate_movies()
|
||||
populate_status["movies"]["status"] = "completed"
|
||||
populate_status["movies"]["stats"] = movie_stats
|
||||
_log("INFO", f"Movie population completed: {movie_stats}")
|
||||
|
||||
populate_status["tv"]["status"] = "running"
|
||||
tv_stats = populator.populate_tv_episodes()
|
||||
populate_status["tv"]["status"] = "completed"
|
||||
populate_status["tv"]["stats"] = tv_stats
|
||||
_log("INFO", f"TV population completed: {tv_stats}")
|
||||
|
||||
populate_status["completed"] = True
|
||||
populate_status["running"] = False
|
||||
_log("INFO", "Database population completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database population failed: {e}")
|
||||
populate_status["error"] = str(e)
|
||||
populate_status["running"] = False
|
||||
populate_status["completed"] = True
|
||||
|
||||
# Add task to background
|
||||
background_tasks.add_task(run_population)
|
||||
|
||||
_log("INFO", f"Database population started for: {media_type}")
|
||||
return {
|
||||
"status": "started",
|
||||
"media_type": media_type,
|
||||
"message": f"Database population started for {media_type}"
|
||||
}
|
||||
|
||||
|
||||
async def get_populate_status():
|
||||
"""Get the current status of database population"""
|
||||
global _populate_status
|
||||
if '_populate_status' not in globals():
|
||||
return {"running": False, "completed": False}
|
||||
return _populate_status
|
||||
|
||||
|
||||
# Initialize global populate status
|
||||
_populate_status = {"running": False, "completed": False}
|
||||
|
||||
|
||||
def register_routes(app, dependencies: dict):
|
||||
"""
|
||||
Register all routes with the FastAPI app
|
||||
@@ -2750,14 +2296,6 @@ def register_routes(app, dependencies: dict):
|
||||
async def _scan_status():
|
||||
return await get_scan_status()
|
||||
|
||||
@app.post("/admin/populate-database")
|
||||
async def _populate_database(background_tasks: BackgroundTasks, media_type: str = "both"):
|
||||
return await populate_database(background_tasks, media_type, dependencies)
|
||||
|
||||
@app.get("/api/populate/status")
|
||||
async def _populate_status():
|
||||
return await get_populate_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)
|
||||
@@ -2786,40 +2324,6 @@ def register_routes(app, dependencies: dict):
|
||||
async def _debug_tmdb_lookup(imdb_id: str):
|
||||
return await debug_tmdb_lookup(imdb_id, dependencies)
|
||||
|
||||
# ---------------------------
|
||||
# Emby Plugin Lookup Endpoints
|
||||
# ---------------------------
|
||||
|
||||
@app.get("/api/lookup/episode/{imdb_id}/{season}/{episode}")
|
||||
async def _lookup_episode(imdb_id: str, season: int, episode: int):
|
||||
return await lookup_episode(imdb_id, season, episode, dependencies)
|
||||
|
||||
@app.get("/api/lookup/movie/{imdb_id}")
|
||||
async def _lookup_movie(imdb_id: str):
|
||||
return await lookup_movie(imdb_id, dependencies)
|
||||
|
||||
@app.get("/api/lookup/movie/title/{title}")
|
||||
async def _lookup_movie_by_title(title: str, year: str = None):
|
||||
return await lookup_movie_by_title(title, year, dependencies)
|
||||
|
||||
@app.post("/api/lookup/movie/path")
|
||||
async def _lookup_movie_by_path(request: Request):
|
||||
data = await request.json()
|
||||
nfo_path = data.get("nfo_path")
|
||||
if not nfo_path:
|
||||
raise HTTPException(status_code=400, detail="nfo_path required")
|
||||
return await lookup_movie_comprehensive(nfo_path, dependencies)
|
||||
|
||||
@app.get("/api/debug/movie/{title}")
|
||||
async def _debug_movie_lookup(title: str):
|
||||
"""Debug endpoint to check what movies exist for a given title"""
|
||||
db = dependencies.get("db")
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT imdb_id, title, dateadded, source FROM movies WHERE LOWER(title) LIKE LOWER(%s)", (f"%{title}%",))
|
||||
movies = cursor.fetchall()
|
||||
return {"title_search": title, "matches": [dict(row) for row in movies]}
|
||||
|
||||
# Include monitoring routes
|
||||
from api.monitoring_routes import router as monitoring_router
|
||||
app.include_router(monitoring_router)
|
||||
|
||||
+20
-777
@@ -3,25 +3,14 @@ Web interface API routes for NFOGuard database management
|
||||
Provides endpoints for the web-based database manipulation interface
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import multiprocessing
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import HTTPException, Query, BackgroundTasks
|
||||
from fastapi import HTTPException, Query
|
||||
from pathlib import Path
|
||||
|
||||
from api.models import *
|
||||
|
||||
|
||||
# Status file for cross-process communication
|
||||
# Use /tmp which is writable in both core and web containers
|
||||
POPULATE_STATUS_FILE = "/tmp/nfoguard_populate_status.json"
|
||||
|
||||
# Process tracking
|
||||
_populate_process = None
|
||||
|
||||
|
||||
def map_source_to_description(source: str) -> str:
|
||||
"""Map technical source codes to user-friendly descriptions"""
|
||||
if not source or source == "no_valid_date_source":
|
||||
@@ -56,23 +45,15 @@ def map_source_to_description(source: str) -> str:
|
||||
elif "omdb:" in source_lower:
|
||||
return "OMDb Release"
|
||||
|
||||
# Sonarr sources
|
||||
elif "sonarr:" in source_lower:
|
||||
return "Sonarr API"
|
||||
|
||||
# Manual and other sources
|
||||
elif "manual" in source_lower:
|
||||
return "Manual Entry"
|
||||
elif "digital_release" in source_lower:
|
||||
return "Digital Release"
|
||||
elif "nfo_file_existing" in source_lower:
|
||||
return "NFO File (Legacy)"
|
||||
elif "nfo:" in source_lower:
|
||||
return "NFO File"
|
||||
elif "webhook:" in source_lower:
|
||||
return "Webhook/API"
|
||||
elif "database" in source_lower:
|
||||
return "Database"
|
||||
|
||||
# Fallback for unknown patterns
|
||||
return source.title()
|
||||
@@ -88,8 +69,7 @@ async def get_movies_list(dependencies: dict,
|
||||
has_date: Optional[bool] = Query(None),
|
||||
source_filter: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
imdb_search: Optional[str] = Query(None),
|
||||
skipped: Optional[bool] = Query(None)):
|
||||
imdb_search: Optional[str] = Query(None)):
|
||||
"""Get paginated list of movies with filtering options"""
|
||||
db = dependencies["db"]
|
||||
|
||||
@@ -100,12 +80,6 @@ async def get_movies_list(dependencies: dict,
|
||||
where_conditions = []
|
||||
params = []
|
||||
|
||||
if skipped is not None:
|
||||
if skipped:
|
||||
where_conditions.append("skipped = TRUE")
|
||||
else:
|
||||
where_conditions.append("(skipped = FALSE OR skipped IS NULL)")
|
||||
|
||||
if has_date is not None:
|
||||
if has_date:
|
||||
# PostgreSQL - NULL handling
|
||||
@@ -119,8 +93,8 @@ async def get_movies_list(dependencies: dict,
|
||||
params.append(source_filter)
|
||||
|
||||
if search:
|
||||
where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s OR title ILIKE %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
||||
where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s)")
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
if imdb_search:
|
||||
where_conditions.append("imdb_id ILIKE %s")
|
||||
@@ -135,7 +109,7 @@ async def get_movies_list(dependencies: dict,
|
||||
|
||||
# Get paginated results - PostgreSQL
|
||||
query = f"""
|
||||
SELECT imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated, skipped, skip_reason
|
||||
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
|
||||
FROM movies
|
||||
WHERE {where_clause}
|
||||
ORDER BY last_updated DESC
|
||||
@@ -146,8 +120,7 @@ async def get_movies_list(dependencies: dict,
|
||||
movies = []
|
||||
for row in cursor.fetchall():
|
||||
movie = dict(row)
|
||||
# Use database title if available, otherwise extract from path
|
||||
if not movie.get('title'):
|
||||
# Extract title from path for display
|
||||
try:
|
||||
movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
|
||||
except:
|
||||
@@ -177,8 +150,8 @@ async def get_tv_series_list(dependencies: dict,
|
||||
db = dependencies["db"]
|
||||
|
||||
# Validate date_filter values
|
||||
if date_filter and date_filter not in ['complete', 'incomplete', 'none', 'skipped']:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid date_filter: must be 'complete', 'incomplete', 'none', or 'skipped', got '{date_filter}'")
|
||||
if date_filter and date_filter not in ['complete', 'incomplete', 'none']:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid date_filter: must be 'complete', 'incomplete', or 'none', got '{date_filter}'")
|
||||
|
||||
with db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -211,19 +184,13 @@ async def get_tv_series_list(dependencies: dict,
|
||||
elif date_filter == "none":
|
||||
# No episodes have dates
|
||||
having_conditions.append("COUNT(e.episode) > 0 AND COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) = 0")
|
||||
elif date_filter == "skipped":
|
||||
# Has skipped episodes
|
||||
having_conditions.append("COUNT(CASE WHEN e.skipped = TRUE THEN 1 END) > 0")
|
||||
|
||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
having_clause = " AND ".join(having_conditions) if having_conditions else ""
|
||||
|
||||
# Check if where_clause references episodes table (e.source, e.dateadded, etc.)
|
||||
needs_episode_join = any(cond.startswith("e.") for cond in where_conditions)
|
||||
|
||||
# Get total count with same filtering logic as main query
|
||||
if having_clause or needs_episode_join:
|
||||
# When using HAVING clause or filtering by episode fields, need to count filtered results with JOIN
|
||||
if having_clause:
|
||||
# When using HAVING clause, need to count filtered results
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT s.imdb_id
|
||||
@@ -231,12 +198,12 @@ async def get_tv_series_list(dependencies: dict,
|
||||
LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
|
||||
WHERE {where_clause}
|
||||
GROUP BY s.imdb_id
|
||||
{('HAVING ' + having_clause) if having_clause else ''}
|
||||
HAVING {having_clause}
|
||||
) filtered_series
|
||||
"""
|
||||
cursor.execute(count_query, params)
|
||||
else:
|
||||
# Simple count when no HAVING clause and no episode filtering
|
||||
# Simple count when no HAVING clause
|
||||
count_query = f"SELECT COUNT(*) FROM series s WHERE {where_clause}"
|
||||
cursor.execute(count_query, params)
|
||||
total_count = db._get_first_value(cursor.fetchone())
|
||||
@@ -251,8 +218,7 @@ async def get_tv_series_list(dependencies: dict,
|
||||
s.last_updated,
|
||||
COUNT(e.episode) as total_episodes,
|
||||
COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) as episodes_with_dates,
|
||||
COUNT(CASE WHEN e.has_video_file = TRUE THEN 1 END) as episodes_with_video,
|
||||
COUNT(CASE WHEN e.skipped = TRUE THEN 1 END) as episodes_skipped
|
||||
COUNT(CASE WHEN e.has_video_file = TRUE THEN 1 END) as episodes_with_video
|
||||
FROM series s
|
||||
LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
|
||||
WHERE {where_clause}
|
||||
@@ -500,13 +466,6 @@ async def get_dashboard_stats(dependencies: dict):
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
|
||||
episodes_no_valid_source = db._get_first_value(cursor.fetchone())
|
||||
|
||||
# Skipped items
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE skipped = TRUE")
|
||||
movies_skipped = db._get_first_value(cursor.fetchone())
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE skipped = TRUE")
|
||||
episodes_skipped = db._get_first_value(cursor.fetchone())
|
||||
|
||||
# Recent activity (last 7 days)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM processing_history
|
||||
@@ -536,7 +495,6 @@ async def get_dashboard_stats(dependencies: dict):
|
||||
|
||||
# Calculate total missing dates (movies + episodes)
|
||||
total_missing_dates = movies_without_dates + episodes_without_dates
|
||||
total_skipped = movies_skipped + episodes_skipped
|
||||
|
||||
# Combine with enhanced stats
|
||||
stats.update({
|
||||
@@ -549,9 +507,6 @@ async def get_dashboard_stats(dependencies: dict):
|
||||
"total_missing_dates": total_missing_dates,
|
||||
"movies_no_valid_source": movies_no_valid_source,
|
||||
"episodes_no_valid_source": episodes_no_valid_source,
|
||||
"movies_skipped": movies_skipped,
|
||||
"episodes_skipped": episodes_skipped,
|
||||
"total_skipped": total_skipped,
|
||||
"recent_activity_count": recent_activity,
|
||||
"movie_sources": movie_sources,
|
||||
"episode_sources": episode_sources
|
||||
@@ -767,6 +722,7 @@ async def bulk_update_source(dependencies: dict, media_type: str, old_source: st
|
||||
async def get_movie_date_options(dependencies: dict, imdb_id: str):
|
||||
"""Get available date options for a movie (Radarr import, digital release, etc.)"""
|
||||
db = dependencies["db"]
|
||||
nfo_manager = dependencies["nfo_manager"]
|
||||
|
||||
# Get current movie data
|
||||
movie = db.get_movie_dates(imdb_id)
|
||||
@@ -1278,195 +1234,6 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
|
||||
}
|
||||
|
||||
|
||||
def _populate_worker_process(media_type: str, status_file: str):
|
||||
"""
|
||||
Worker process that runs database population in complete isolation.
|
||||
Runs in separate process - keeps web interface responsive.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
def update_status(status_data):
|
||||
"""Write status to file for main process to read"""
|
||||
try:
|
||||
with open(status_file, 'w') as f:
|
||||
json.dump(status_data, f)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to update status file: {e}")
|
||||
|
||||
# Initialize status
|
||||
status = {
|
||||
"running": True,
|
||||
"media_type": media_type,
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"movies": {"status": "pending", "stats": None},
|
||||
"tv": {"status": "pending", "stats": None},
|
||||
"completed": False,
|
||||
"error": None
|
||||
}
|
||||
update_status(status)
|
||||
|
||||
try:
|
||||
# Import here (inside process) to avoid pickling issues
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.database_populator import DatabasePopulator
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from config.settings import config
|
||||
|
||||
# Initialize components
|
||||
db = NFOGuardDatabase(config)
|
||||
radarr_client = RadarrClient(
|
||||
os.environ.get("RADARR_URL", ""),
|
||||
os.environ.get("RADARR_API_KEY", "")
|
||||
)
|
||||
sonarr_client = SonarrClient(
|
||||
os.environ.get("SONARR_URL", ""),
|
||||
os.environ.get("SONARR_API_KEY", "")
|
||||
)
|
||||
|
||||
populator = DatabasePopulator(db, radarr_client, sonarr_client)
|
||||
|
||||
print(f"INFO: [Worker Process] Starting database population: {media_type}")
|
||||
|
||||
# Run population based on media type
|
||||
if media_type in ["movies", "both"]:
|
||||
status["movies"]["status"] = "running"
|
||||
update_status(status)
|
||||
|
||||
movie_stats = populator.populate_movies()
|
||||
|
||||
status["movies"]["status"] = "completed"
|
||||
status["movies"]["stats"] = movie_stats
|
||||
update_status(status)
|
||||
print(f"INFO: [Worker Process] Movie population completed: {movie_stats}")
|
||||
|
||||
if media_type in ["tv", "both"]:
|
||||
status["tv"]["status"] = "running"
|
||||
update_status(status)
|
||||
|
||||
tv_stats = populator.populate_tv_episodes()
|
||||
|
||||
status["tv"]["status"] = "completed"
|
||||
status["tv"]["stats"] = tv_stats
|
||||
update_status(status)
|
||||
print(f"INFO: [Worker Process] TV population completed: {tv_stats}")
|
||||
|
||||
# Mark as completed
|
||||
status["completed"] = True
|
||||
status["running"] = False
|
||||
update_status(status)
|
||||
print("INFO: [Worker Process] Database population completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: [Worker Process] Database population failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
status["error"] = str(e)
|
||||
status["running"] = False
|
||||
status["completed"] = True
|
||||
update_status(status)
|
||||
|
||||
|
||||
async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None):
|
||||
"""
|
||||
Populate NFOGuard database from Radarr/Sonarr sources in separate process.
|
||||
This keeps the web interface responsive during population.
|
||||
|
||||
Args:
|
||||
background_tasks: FastAPI background tasks (not used - kept for compatibility)
|
||||
media_type: Type of media to populate ("movies", "tv", or "both")
|
||||
dependencies: Dictionary with dependencies (not used in multiprocessing mode)
|
||||
|
||||
Returns:
|
||||
Status message indicating population has started
|
||||
"""
|
||||
global _populate_process
|
||||
|
||||
if media_type not in ["both", "movies", "tv"]:
|
||||
raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'")
|
||||
|
||||
# Check if process is already running
|
||||
if _populate_process and _populate_process.is_alive():
|
||||
return {
|
||||
"status": "already_running",
|
||||
"message": "Database population is already in progress"
|
||||
}
|
||||
|
||||
# Initialize status file
|
||||
initial_status = {
|
||||
"running": True,
|
||||
"media_type": media_type,
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"movies": {"status": "pending", "stats": None},
|
||||
"tv": {"status": "pending", "stats": None},
|
||||
"completed": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
with open(POPULATE_STATUS_FILE, 'w') as f:
|
||||
json.dump(initial_status, f)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to initialize status file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to initialize status tracking: {e}")
|
||||
|
||||
# Start population in separate process
|
||||
_populate_process = multiprocessing.Process(
|
||||
target=_populate_worker_process,
|
||||
args=(media_type, POPULATE_STATUS_FILE),
|
||||
daemon=False # Keep process alive even if parent exits
|
||||
)
|
||||
_populate_process.start()
|
||||
|
||||
print(f"INFO: Database population process started (PID: {_populate_process.pid}) for: {media_type}")
|
||||
return {
|
||||
"status": "started",
|
||||
"media_type": media_type,
|
||||
"message": f"Database population started for {media_type} in separate process (PID: {_populate_process.pid})"
|
||||
}
|
||||
|
||||
|
||||
async def get_populate_status():
|
||||
"""Get the current status of database population from status file"""
|
||||
global _populate_process
|
||||
|
||||
# Check if status file exists
|
||||
if not os.path.exists(POPULATE_STATUS_FILE):
|
||||
return {"running": False, "completed": False}
|
||||
|
||||
# Read status from file
|
||||
try:
|
||||
with open(POPULATE_STATUS_FILE, 'r') as f:
|
||||
status = json.load(f)
|
||||
|
||||
# Check if process is still alive
|
||||
if _populate_process:
|
||||
status["process_alive"] = _populate_process.is_alive()
|
||||
if not _populate_process.is_alive() and status.get("running"):
|
||||
# Process died unexpectedly
|
||||
status["running"] = False
|
||||
status["completed"] = True
|
||||
if not status.get("error"):
|
||||
status["error"] = "Process terminated unexpectedly"
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to read status file: {e}")
|
||||
return {
|
||||
"running": False,
|
||||
"completed": True,
|
||||
"error": f"Failed to read status: {e}"
|
||||
}
|
||||
|
||||
|
||||
def register_web_routes(app, dependencies):
|
||||
"""Register all web API routes with FastAPI app"""
|
||||
from fastapi import Request, Response
|
||||
@@ -1483,58 +1250,21 @@ def register_web_routes(app, dependencies):
|
||||
# Movies endpoints
|
||||
@app.get("/api/movies")
|
||||
async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None,
|
||||
source_filter: str = None, search: str = None, imdb_search: str = None,
|
||||
skipped: bool = None):
|
||||
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search, skipped)
|
||||
source_filter: str = None, search: str = None, imdb_search: str = None):
|
||||
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
|
||||
|
||||
@app.post("/api/movies/{imdb_id}/update-date")
|
||||
async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return await update_movie_date(dependencies, imdb_id, dateadded, source)
|
||||
|
||||
@app.put("/api/movies/{imdb_id}")
|
||||
async def api_update_movie(imdb_id: str, request: Request):
|
||||
"""Update movie date from JSON body"""
|
||||
try:
|
||||
data = await request.json()
|
||||
dateadded = data.get('dateadded')
|
||||
source = data.get('source', 'manual')
|
||||
|
||||
print(f"🔍 API PUT /api/movies/{imdb_id} - Received JSON:")
|
||||
print(f" - Raw data: {data}")
|
||||
print(f" - dateadded: {dateadded}")
|
||||
print(f" - source: {source}")
|
||||
|
||||
async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return await update_movie_date(dependencies, imdb_id, dateadded, source)
|
||||
except Exception as e:
|
||||
print(f"❌ Error parsing request body: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Invalid request body: {str(e)}")
|
||||
|
||||
@app.get("/api/movies/{imdb_id}/date-options")
|
||||
async def api_movie_date_options(imdb_id: str):
|
||||
return await get_movie_date_options(dependencies, imdb_id)
|
||||
|
||||
@app.get("/api/debug/movie/{imdb_id}/raw")
|
||||
async def api_debug_movie_raw(imdb_id: str):
|
||||
"""Get raw database data for a movie for debugging"""
|
||||
db = dependencies["db"]
|
||||
|
||||
try:
|
||||
movie = db.get_movie_dates(imdb_id)
|
||||
|
||||
if not movie:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"raw_data": movie
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting movie debug data: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get debug data: {str(e)}")
|
||||
|
||||
@app.delete("/api/movies/{imdb_id}")
|
||||
async def api_delete_movie(imdb_id: str):
|
||||
"""Delete a movie from the database"""
|
||||
@@ -1560,41 +1290,6 @@ def register_web_routes(app, dependencies):
|
||||
print(f"❌ Error deleting movie: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}")
|
||||
|
||||
@app.post("/api/movies/{old_imdb_id}/migrate-imdb")
|
||||
async def api_migrate_movie_imdb(old_imdb_id: str, request: Request):
|
||||
"""Migrate a movie from placeholder IMDb ID to real IMDb ID"""
|
||||
db = dependencies["db"]
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
new_imdb_id = data.get('new_imdb_id')
|
||||
|
||||
if not new_imdb_id:
|
||||
raise HTTPException(status_code=422, detail="new_imdb_id is required")
|
||||
|
||||
# Normalize IMDb ID
|
||||
if not new_imdb_id.startswith('tt'):
|
||||
new_imdb_id = f"tt{new_imdb_id}"
|
||||
|
||||
success = db.migrate_movie_imdb_id(old_imdb_id, new_imdb_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"message": f"Migrated movie from {old_imdb_id} to {new_imdb_id}",
|
||||
"old_imdb_id": old_imdb_id,
|
||||
"new_imdb_id": new_imdb_id
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"❌ Error migrating movie IMDb ID: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to migrate IMDb ID: {str(e)}")
|
||||
|
||||
# TV series endpoints
|
||||
@app.get("/api/series")
|
||||
async def api_series_list(skip: int = 0, limit: int = 50, search: str = None,
|
||||
@@ -1613,41 +1308,6 @@ def register_web_routes(app, dependencies):
|
||||
async def api_debug_series_date_distribution():
|
||||
return await debug_series_date_distribution(dependencies)
|
||||
|
||||
@app.post("/api/series/{old_imdb_id}/migrate-imdb")
|
||||
async def api_migrate_series_imdb(old_imdb_id: str, request: Request):
|
||||
"""Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID"""
|
||||
db = dependencies["db"]
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
new_imdb_id = data.get('new_imdb_id')
|
||||
|
||||
if not new_imdb_id:
|
||||
raise HTTPException(status_code=422, detail="new_imdb_id is required")
|
||||
|
||||
# Normalize IMDb ID
|
||||
if not new_imdb_id.startswith('tt'):
|
||||
new_imdb_id = f"tt{new_imdb_id}"
|
||||
|
||||
success = db.migrate_series_imdb_id(old_imdb_id, new_imdb_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"message": f"Migrated series and episodes from {old_imdb_id} to {new_imdb_id}",
|
||||
"old_imdb_id": old_imdb_id,
|
||||
"new_imdb_id": new_imdb_id
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Series not found")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"❌ Error migrating series IMDb ID: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to migrate IMDb ID: {str(e)}")
|
||||
|
||||
# Episode endpoints
|
||||
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
|
||||
async def api_update_episode_date(imdb_id: str, season: int, episode: int,
|
||||
@@ -1702,78 +1362,6 @@ def register_web_routes(app, dependencies):
|
||||
async def api_bulk_update_source(media_type: str, old_source: str, new_source: str):
|
||||
return {"error": "Bulk operations not available in web container. Use core container on port 8085."}
|
||||
|
||||
@app.post("/api/episodes/bulk-update-dates")
|
||||
async def api_bulk_update_episode_dates(request: Request):
|
||||
"""Bulk update episode dates"""
|
||||
try:
|
||||
data = await request.json()
|
||||
episodes = data.get('episodes', [])
|
||||
date_source = data.get('date_source', 'airdate')
|
||||
custom_date = data.get('custom_date')
|
||||
|
||||
if not episodes:
|
||||
return {"success": False, "message": "No episodes provided"}
|
||||
|
||||
db = dependencies["db"]
|
||||
sonarr = dependencies.get("sonarr_client")
|
||||
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for ep in episodes:
|
||||
imdb_id = ep.get('imdb_id')
|
||||
season = ep.get('season')
|
||||
episode = ep.get('episode')
|
||||
|
||||
if not all([imdb_id, season is not None, episode is not None]):
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
dateadded = None
|
||||
source = "manual"
|
||||
|
||||
if date_source == 'airdate':
|
||||
# Get airdate from Sonarr
|
||||
if sonarr:
|
||||
episode_data = sonarr.get_episode(imdb_id, season, episode)
|
||||
if episode_data and episode_data.get('airDateUtc'):
|
||||
dateadded = episode_data['airDateUtc']
|
||||
source = "sonarr:airdate"
|
||||
elif date_source == 'import':
|
||||
# Get import date from Sonarr history
|
||||
if sonarr:
|
||||
import_date = sonarr.get_episode_import_date(imdb_id, season, episode)
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = "sonarr:import"
|
||||
elif date_source == 'custom':
|
||||
# Use custom date
|
||||
if custom_date:
|
||||
dateadded = custom_date
|
||||
source = "manual:custom"
|
||||
|
||||
if dateadded:
|
||||
db.upsert_episode_dates(imdb_id, season, episode, dateadded, source)
|
||||
updated_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error updating episode {imdb_id} S{season:02d}E{episode:02d}: {e}")
|
||||
failed_count += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"updated": updated_count,
|
||||
"failed": failed_count,
|
||||
"message": f"Updated {updated_count} episode(s), {failed_count} failed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Bulk update error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Reports
|
||||
@app.get("/api/reports/missing-dates")
|
||||
async def api_missing_dates_report():
|
||||
@@ -1837,8 +1425,8 @@ def register_web_routes(app, dependencies):
|
||||
# Create request with timeout (no body needed for query parameters)
|
||||
req = urllib.request.Request(core_url, method='POST')
|
||||
|
||||
# Make request with timeout (increased for manual scans which can take several minutes)
|
||||
with urllib.request.urlopen(req, timeout=300) as response:
|
||||
# Make request with timeout
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
response_data = response.read().decode('utf-8')
|
||||
return json.loads(response_data)
|
||||
|
||||
@@ -2208,7 +1796,7 @@ async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_a
|
||||
for ep in episodes:
|
||||
try:
|
||||
series_path = Path(ep['series_path'])
|
||||
season_dir = series_path / config.get_season_dir_name(ep['season'])
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=ep['season'])
|
||||
|
||||
if not season_dir.exists():
|
||||
continue
|
||||
@@ -2251,7 +1839,6 @@ async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_a
|
||||
# Add database admin routes to the web interface
|
||||
def register_database_admin_routes(app, dependencies):
|
||||
"""Register database admin routes"""
|
||||
from fastapi import Request, Response
|
||||
|
||||
@app.post("/api/admin/database/query")
|
||||
async def api_database_query(query: str, limit: int = 100):
|
||||
@@ -2311,347 +1898,3 @@ def register_database_admin_routes(app, dependencies):
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Scheduled Scans Endpoints
|
||||
|
||||
@app.get("/api/admin/scheduled-scans")
|
||||
async def api_get_scheduled_scans():
|
||||
"""Get all scheduled scans"""
|
||||
return await get_scheduled_scans(dependencies)
|
||||
|
||||
@app.post("/api/admin/scheduled-scans")
|
||||
async def api_create_scheduled_scan(request: CreateScheduledScanRequest):
|
||||
"""Create a new scheduled scan"""
|
||||
return await create_scheduled_scan(dependencies, request)
|
||||
|
||||
@app.put("/api/admin/scheduled-scans/{scan_id}")
|
||||
async def api_update_scheduled_scan(scan_id: int, request: UpdateScheduledScanRequest):
|
||||
"""Update a scheduled scan"""
|
||||
return await update_scheduled_scan(dependencies, scan_id, request)
|
||||
|
||||
@app.delete("/api/admin/scheduled-scans/{scan_id}")
|
||||
async def api_delete_scheduled_scan(scan_id: int):
|
||||
"""Delete a scheduled scan"""
|
||||
return await delete_scheduled_scan(dependencies, scan_id)
|
||||
|
||||
@app.post("/api/admin/scheduled-scans/{scan_id}/toggle")
|
||||
async def api_toggle_scheduled_scan(scan_id: int):
|
||||
"""Toggle a scheduled scan enabled/disabled"""
|
||||
return await toggle_scheduled_scan(dependencies, scan_id)
|
||||
|
||||
@app.post("/api/admin/scheduled-scans/{scan_id}/run")
|
||||
async def api_run_scheduled_scan(scan_id: int):
|
||||
"""Manually run a scheduled scan"""
|
||||
return await run_scheduled_scan(dependencies, scan_id)
|
||||
|
||||
@app.get("/api/admin/scheduled-scans/executions")
|
||||
async def api_get_schedule_executions(schedule_id: int = None):
|
||||
"""Get schedule execution history"""
|
||||
return await get_schedule_executions(dependencies, schedule_id)
|
||||
|
||||
# Database population endpoints
|
||||
print("🔧 DEBUG: Registering /admin/populate-database endpoint...")
|
||||
|
||||
@app.post("/admin/populate-database")
|
||||
async def api_populate_database(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Populate database from Radarr/Sonarr"""
|
||||
print(f"🔥 DEBUG: populate-database endpoint called!")
|
||||
try:
|
||||
data = await request.json()
|
||||
media_type = data.get("media_type", "both")
|
||||
except Exception:
|
||||
# Fallback to query parameter if JSON parsing fails
|
||||
media_type = request.query_params.get("media_type", "both")
|
||||
return await populate_database(background_tasks, media_type, dependencies)
|
||||
|
||||
print("✅ DEBUG: /admin/populate-database registered")
|
||||
|
||||
@app.get("/api/populate/status")
|
||||
async def api_populate_status():
|
||||
"""Get database population status"""
|
||||
return await get_populate_status()
|
||||
|
||||
|
||||
# Scheduled Scans Functions
|
||||
|
||||
async def get_scheduled_scans(dependencies: dict):
|
||||
"""Get all scheduled scans"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
scans = db.get_scheduled_scans()
|
||||
|
||||
# Convert to response format
|
||||
scan_list = []
|
||||
for scan in scans:
|
||||
scan_dict = dict(scan)
|
||||
# Convert datetime objects to strings
|
||||
for field in ['created_at', 'updated_at', 'last_run_at', 'next_run_at']:
|
||||
if scan_dict.get(field):
|
||||
scan_dict[field] = scan_dict[field].isoformat()
|
||||
scan_list.append(scan_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"scans": scan_list
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def create_scheduled_scan(dependencies: dict, request: CreateScheduledScanRequest):
|
||||
"""Create a new scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Validate cron expression
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(request.cron_expression):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid cron expression"
|
||||
}
|
||||
|
||||
# Validate media type and scan mode
|
||||
if request.media_type not in ['tv', 'movies', 'both']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid media type. Must be 'tv', 'movies', or 'both'"
|
||||
}
|
||||
|
||||
if request.scan_mode not in ['smart', 'full', 'incomplete']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid scan mode. Must be 'smart', 'full', or 'incomplete'"
|
||||
}
|
||||
|
||||
# Create the scheduled scan
|
||||
scan_id = db.create_scheduled_scan(
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
cron_expression=request.cron_expression,
|
||||
media_type=request.media_type,
|
||||
scan_mode=request.scan_mode,
|
||||
specific_paths=request.specific_paths,
|
||||
enabled=request.enabled,
|
||||
created_by="web_interface"
|
||||
)
|
||||
|
||||
# Calculate next run time
|
||||
from croniter import croniter
|
||||
from datetime import datetime, timezone
|
||||
cron = croniter(request.cron_expression, datetime.now(timezone.utc))
|
||||
next_run = cron.get_next(datetime)
|
||||
db.update_scan_next_run(scan_id, next_run)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"scan_id": scan_id,
|
||||
"message": f"Scheduled scan '{request.name}' created successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def update_scheduled_scan(dependencies: dict, scan_id: int, request: UpdateScheduledScanRequest):
|
||||
"""Update a scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# Validate cron expression if provided
|
||||
if request.cron_expression:
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(request.cron_expression):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid cron expression"
|
||||
}
|
||||
|
||||
# Validate media type and scan mode if provided
|
||||
if request.media_type and request.media_type not in ['tv', 'movies', 'both']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid media type. Must be 'tv', 'movies', or 'both'"
|
||||
}
|
||||
|
||||
if request.scan_mode and request.scan_mode not in ['smart', 'full', 'incomplete']:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid scan mode. Must be 'smart', 'full', or 'incomplete'"
|
||||
}
|
||||
|
||||
# Update the scheduled scan
|
||||
success = db.update_scheduled_scan(
|
||||
scan_id=scan_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
cron_expression=request.cron_expression,
|
||||
media_type=request.media_type,
|
||||
scan_mode=request.scan_mode,
|
||||
specific_paths=request.specific_paths,
|
||||
enabled=request.enabled,
|
||||
updated_by="web_interface"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to update scheduled scan"
|
||||
}
|
||||
|
||||
# Update next run time if cron expression changed
|
||||
if request.cron_expression:
|
||||
from croniter import croniter
|
||||
from datetime import datetime, timezone
|
||||
cron = croniter(request.cron_expression, datetime.now(timezone.utc))
|
||||
next_run = cron.get_next(datetime)
|
||||
db.update_scan_next_run(scan_id, next_run)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Scheduled scan updated successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def delete_scheduled_scan(dependencies: dict, scan_id: int):
|
||||
"""Delete a scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# Delete the scheduled scan
|
||||
success = db.delete_scheduled_scan(scan_id)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to delete scheduled scan"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Scheduled scan '{existing_scan['name']}' deleted successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def toggle_scheduled_scan(dependencies: dict, scan_id: int):
|
||||
"""Toggle a scheduled scan enabled/disabled"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# Toggle enabled status
|
||||
new_enabled = not existing_scan['enabled']
|
||||
success = db.update_scheduled_scan(
|
||||
scan_id=scan_id,
|
||||
enabled=new_enabled,
|
||||
updated_by="web_interface"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Failed to toggle scheduled scan"
|
||||
}
|
||||
|
||||
status = "enabled" if new_enabled else "disabled"
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Scheduled scan '{existing_scan['name']}' {status} successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def run_scheduled_scan(dependencies: dict, scan_id: int):
|
||||
"""Manually run a scheduled scan"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
|
||||
# Check if scan exists
|
||||
existing_scan = db.get_scheduled_scan(scan_id)
|
||||
if not existing_scan:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Scheduled scan not found"
|
||||
}
|
||||
|
||||
# TODO: Implement actual scan execution
|
||||
# For now, just return a success message
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Manual execution of '{existing_scan['name']}' started",
|
||||
"note": "Scan execution will be implemented with the background scheduler"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def get_schedule_executions(dependencies: dict, schedule_id: int = None):
|
||||
"""Get schedule execution history"""
|
||||
try:
|
||||
db = dependencies["db"]
|
||||
executions = db.get_schedule_executions(schedule_id)
|
||||
|
||||
# Convert to response format
|
||||
execution_list = []
|
||||
for execution in executions:
|
||||
execution_dict = dict(execution)
|
||||
# Convert datetime objects to strings
|
||||
for field in ['started_at', 'completed_at']:
|
||||
if execution_dict.get(field):
|
||||
execution_dict[field] = execution_dict[field].isoformat()
|
||||
execution_list.append(execution_dict)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"executions": execution_list
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
@@ -165,53 +165,6 @@ class RadarrDbClient:
|
||||
|
||||
return None
|
||||
|
||||
def get_all_movies(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all movies from Radarr database (only movies with files)
|
||||
|
||||
Returns:
|
||||
List of dictionaries with movie info
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
m."Id" as id,
|
||||
m."Path" as path,
|
||||
m."Added" as added,
|
||||
mm."ImdbId" as imdb_id,
|
||||
mm."Title" as title,
|
||||
mm."Year" as year,
|
||||
mm."DigitalRelease" as digital_release,
|
||||
mm."PhysicalRelease" as physical_release,
|
||||
mm."InCinemas" as in_cinemas,
|
||||
(SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") as file_count
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE (SELECT COUNT(*) FROM "MovieFiles" mf WHERE mf."MovieId" = m."Id") > 0
|
||||
ORDER BY mm."Title"
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
# SQLite uses ? placeholders but this query has none
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
return [dict(row) if self.db_type == "sqlite" else row for row in rows]
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error getting all movies: {e}")
|
||||
return []
|
||||
|
||||
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date from History table, accounting for upgrade scenarios
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct Sonarr Database Client for NFOGuard
|
||||
Provides high-performance access to Sonarr's SQLite/PostgreSQL database
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class SonarrDbClient:
|
||||
"""Direct database client for Sonarr's SQLite or PostgreSQL database"""
|
||||
|
||||
def __init__(self,
|
||||
db_type: str = "sqlite",
|
||||
db_path: Optional[str] = None,
|
||||
db_host: Optional[str] = None,
|
||||
db_port: Optional[int] = None,
|
||||
db_name: Optional[str] = None,
|
||||
db_user: Optional[str] = None,
|
||||
db_password: Optional[str] = None):
|
||||
"""
|
||||
Initialize Sonarr database client
|
||||
|
||||
Args:
|
||||
db_type: "sqlite" or "postgresql"
|
||||
db_path: Path to SQLite database file
|
||||
db_host: PostgreSQL host
|
||||
db_port: PostgreSQL port
|
||||
db_name: PostgreSQL database name
|
||||
db_user: PostgreSQL username
|
||||
db_password: PostgreSQL password
|
||||
"""
|
||||
self.db_type = db_type.lower()
|
||||
self.db_path = db_path
|
||||
self.db_host = db_host
|
||||
self.db_port = db_port or 5432
|
||||
self.db_name = db_name
|
||||
self.db_user = db_user
|
||||
self.db_password = db_password
|
||||
|
||||
self._test_connection()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Optional['SonarrDbClient']:
|
||||
"""Create client from environment variables"""
|
||||
db_type = os.environ.get("SONARR_DB_TYPE", "").lower()
|
||||
|
||||
if not db_type:
|
||||
return None
|
||||
|
||||
if db_type == "sqlite":
|
||||
db_path = os.environ.get("SONARR_DB_PATH")
|
||||
if not db_path or not Path(db_path).exists():
|
||||
_log("WARNING", f"SONARR_DB_PATH not found or invalid: {db_path}")
|
||||
return None
|
||||
return cls(db_type="sqlite", db_path=db_path)
|
||||
|
||||
elif db_type == "postgresql":
|
||||
# Support both individual vars and connection string
|
||||
db_url = os.environ.get("SONARR_DB_URL")
|
||||
if db_url:
|
||||
parsed = urlparse(db_url)
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=parsed.hostname,
|
||||
db_port=parsed.port or 5432,
|
||||
db_name=parsed.path.lstrip('/'),
|
||||
db_user=parsed.username,
|
||||
db_password=parsed.password
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=os.environ.get("SONARR_DB_HOST"),
|
||||
db_port=int(os.environ.get("SONARR_DB_PORT", "5432")),
|
||||
db_name=os.environ.get("SONARR_DB_NAME"),
|
||||
db_user=os.environ.get("SONARR_DB_USER"),
|
||||
db_password=os.environ.get("SONARR_DB_PASSWORD")
|
||||
)
|
||||
else:
|
||||
_log("ERROR", f"Unsupported database type: {db_type}")
|
||||
return None
|
||||
|
||||
def _test_connection(self) -> None:
|
||||
"""Test database connection on initialization"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
if conn:
|
||||
conn.close()
|
||||
_log("INFO", f"Connected to Sonarr {self.db_type} database successfully")
|
||||
else:
|
||||
raise Exception("Failed to create connection")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to connect to Sonarr database: {e}")
|
||||
raise
|
||||
|
||||
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
|
||||
"""Get database connection"""
|
||||
if self.db_type == "sqlite":
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
elif self.db_type == "postgresql":
|
||||
conn = psycopg2.connect(
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
user=self.db_user,
|
||||
password=self.db_password
|
||||
)
|
||||
return conn
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {self.db_type}")
|
||||
|
||||
def get_series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find series by IMDb ID using database query
|
||||
|
||||
Returns:
|
||||
Dictionary with series info including id, imdb_id, title, path
|
||||
"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
"Id" as id,
|
||||
"ImdbId" as imdb_id,
|
||||
"TvdbId" as tvdb_id,
|
||||
"Title" as title,
|
||||
"Path" as path,
|
||||
"Added" as added
|
||||
FROM "Series"
|
||||
WHERE "ImdbId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (imdb_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row) if self.db_type == "sqlite" else row
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_all_episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all episodes for a series
|
||||
|
||||
Args:
|
||||
series_id: Sonarr series ID
|
||||
|
||||
Returns:
|
||||
List of episode dictionaries with season, episode, air_date
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
"Id" as id,
|
||||
"SeasonNumber" as season,
|
||||
"EpisodeNumber" as episode,
|
||||
"Title" as title,
|
||||
"AirDate" as air_date,
|
||||
"EpisodeFileId" as episode_file_id
|
||||
FROM "Episodes"
|
||||
WHERE "SeriesId" = %s
|
||||
ORDER BY "SeasonNumber", "EpisodeNumber"
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (series_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
return [dict(row) for row in rows]
|
||||
else:
|
||||
return rows
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for series {series_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_episode_import_date(self, episode_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date for an episode from History table
|
||||
|
||||
Args:
|
||||
episode_id: Sonarr episode ID
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Query for earliest import event (EventType 3)
|
||||
import_query = """
|
||||
SELECT
|
||||
"Date" as event_date,
|
||||
"EventType" as event_type
|
||||
FROM "History"
|
||||
WHERE "EpisodeId" = %s
|
||||
AND "EventType" = 3
|
||||
ORDER BY "Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
import_query = import_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try import events first
|
||||
cursor.execute(import_query, (episode_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
return date_iso, "sonarr:db.history.import"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for episode {episode_id}: {e}")
|
||||
|
||||
return None, "sonarr:db.no_import_found"
|
||||
|
||||
def get_episode_file_date(self, series_id: int, season: int, episode: int) -> Optional[str]:
|
||||
"""
|
||||
Get episode file DateAdded as fallback
|
||||
|
||||
Args:
|
||||
series_id: Sonarr series ID
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
|
||||
Returns:
|
||||
ISO date string or None
|
||||
"""
|
||||
# First get the episode ID to find the file
|
||||
episode_query = """
|
||||
SELECT "EpisodeFileId"
|
||||
FROM "Episodes"
|
||||
WHERE "SeriesId" = %s
|
||||
AND "SeasonNumber" = %s
|
||||
AND "EpisodeNumber" = %s
|
||||
"""
|
||||
|
||||
file_query = """
|
||||
SELECT "DateAdded"
|
||||
FROM "EpisodeFiles"
|
||||
WHERE "Id" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
episode_query = episode_query.replace("%s", "?")
|
||||
file_query = file_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get episode file ID
|
||||
cursor.execute(episode_query, (series_id, season, episode))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
file_id = row['EpisodeFileId'] if self.db_type == "postgresql" else row[0]
|
||||
|
||||
if file_id:
|
||||
# Get file date
|
||||
cursor.execute(file_query, (file_id,))
|
||||
file_row = cursor.fetchone()
|
||||
|
||||
if file_row:
|
||||
date_value = file_row['DateAdded'] if self.db_type == "postgresql" else file_row[0]
|
||||
|
||||
if isinstance(date_value, str):
|
||||
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = date_value.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for episode file: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def bulk_import_dates_for_series(self, series_id: int) -> Dict[Tuple[int, int], Tuple[Optional[str], str]]:
|
||||
"""
|
||||
Get import dates for all episodes in a series in a single query
|
||||
|
||||
Args:
|
||||
series_id: Sonarr series ID
|
||||
|
||||
Returns:
|
||||
Dictionary mapping (season, episode) -> (date_iso, source)
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
e."SeasonNumber" as season,
|
||||
e."EpisodeNumber" as episode,
|
||||
e."Id" as episode_id,
|
||||
MIN(h."Date") as earliest_import
|
||||
FROM "Episodes" e
|
||||
LEFT JOIN "History" h ON e."Id" = h."EpisodeId" AND h."EventType" = 3
|
||||
WHERE e."SeriesId" = %s
|
||||
GROUP BY e."SeasonNumber", e."EpisodeNumber", e."Id"
|
||||
ORDER BY e."SeasonNumber", e."EpisodeNumber"
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
results = {}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (series_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
if self.db_type == "postgresql":
|
||||
season, episode, episode_id, earliest_import = row['season'], row['episode'], row['episode_id'], row['earliest_import']
|
||||
else:
|
||||
season, episode, episode_id, earliest_import = row[0], row[1], row[2], row[3]
|
||||
|
||||
if earliest_import:
|
||||
if isinstance(earliest_import, str):
|
||||
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = earliest_import.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
results[(season, episode)] = (date_iso, "sonarr:db.bulk.import")
|
||||
else:
|
||||
results[(season, episode)] = (None, "sonarr:db.bulk.no_import")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Bulk query error for series {series_id}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def get_database_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic statistics about the Sonarr database"""
|
||||
stats = {}
|
||||
|
||||
queries = {
|
||||
"total_series": 'SELECT COUNT(*) FROM "Series"',
|
||||
"total_episodes": 'SELECT COUNT(*) FROM "Episodes"',
|
||||
"total_episode_files": 'SELECT COUNT(*) FROM "EpisodeFiles"',
|
||||
"total_history_events": 'SELECT COUNT(*) FROM "History"',
|
||||
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
|
||||
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for stat_name, query in queries.items():
|
||||
cursor.execute(query)
|
||||
result = cursor.fetchone()
|
||||
stats[stat_name] = result[0] if result else 0
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Stats query error: {e}")
|
||||
stats["error"] = str(e)
|
||||
|
||||
return stats
|
||||
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Comprehensive health check for the Sonarr database connection
|
||||
|
||||
Returns:
|
||||
Dictionary with health status, connection info, and basic functionality tests
|
||||
"""
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"database_type": self.db_type,
|
||||
"connection": "ok",
|
||||
"readable": False,
|
||||
"tables_exist": False,
|
||||
"sample_data": False,
|
||||
"issues": [],
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test 1: Basic read
|
||||
try:
|
||||
cursor.execute('SELECT 1')
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
health["readable"] = True
|
||||
health["connection"] = "readable"
|
||||
else:
|
||||
health["issues"].append("Basic SELECT query failed")
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Read test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 2: Check required tables
|
||||
required_tables = ["Series", "Episodes", "EpisodeFiles", "History"]
|
||||
existing_tables = []
|
||||
|
||||
try:
|
||||
if self.db_type == "postgresql":
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
|
||||
""")
|
||||
else: # SQLite
|
||||
cursor.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name IN ('Series', 'Episodes', 'EpisodeFiles', 'History')
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
existing_tables = [row[0] for row in rows]
|
||||
|
||||
if len(existing_tables) == len(required_tables):
|
||||
health["tables_exist"] = True
|
||||
else:
|
||||
missing = set(required_tables) - set(existing_tables)
|
||||
health["issues"].append(f"Missing tables: {list(missing)}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
health["existing_tables"] = existing_tables
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Table check failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 3: Check for sample data
|
||||
if health["tables_exist"]:
|
||||
try:
|
||||
cursor.execute('SELECT COUNT(*) FROM "Series"')
|
||||
series_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "Episodes"')
|
||||
episode_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "History"')
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
if series_count > 0 and episode_count > 0:
|
||||
health["sample_data"] = True
|
||||
health["series_count"] = series_count
|
||||
health["episode_count"] = episode_count
|
||||
health["history_count"] = history_count
|
||||
else:
|
||||
health["issues"].append(f"Low data counts - Series: {series_count}, Episodes: {episode_count}")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Sample data check failed: {e}")
|
||||
|
||||
# Test 4: Test a real query
|
||||
if health["sample_data"]:
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM "Series"
|
||||
WHERE "ImdbId" IS NOT NULL
|
||||
""")
|
||||
imdb_series = cursor.fetchone()[0]
|
||||
health["series_with_imdb"] = imdb_series
|
||||
|
||||
if imdb_series > 0:
|
||||
health["functional"] = True
|
||||
else:
|
||||
health["issues"].append("No series with IMDb IDs found")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Functional test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
except Exception as e:
|
||||
health["status"] = "error"
|
||||
health["connection"] = "failed"
|
||||
health["issues"].append(f"Connection failed: {e}")
|
||||
_log("ERROR", f"Database health check failed: {e}")
|
||||
|
||||
# Overall status
|
||||
if health["issues"]:
|
||||
if health["status"] == "healthy":
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Add connection details (safe info only)
|
||||
health["connection_info"] = {
|
||||
"type": self.db_type,
|
||||
"host": self.db_host if self.db_type == "postgresql" else None,
|
||||
"port": self.db_port if self.db_type == "postgresql" else None,
|
||||
"database": self.db_name if self.db_type == "postgresql" else None,
|
||||
"path": self.db_path if self.db_type == "sqlite" else None
|
||||
}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the database client
|
||||
print("Testing SonarrDbClient...")
|
||||
|
||||
# Test with environment variables
|
||||
client = SonarrDbClient.from_env()
|
||||
if client:
|
||||
print("✅ Connected to Sonarr database")
|
||||
|
||||
# Test stats
|
||||
stats = client.get_database_stats()
|
||||
print(f"Database stats: {stats}")
|
||||
|
||||
# Test series lookup
|
||||
test_series = client.get_series_by_imdb("tt1628033") # Top Gear from your data
|
||||
if test_series:
|
||||
print(f"Found test series: {test_series}")
|
||||
|
||||
# Test episodes
|
||||
series_id = test_series['id']
|
||||
episodes = client.get_all_episodes_for_series(series_id)
|
||||
print(f"Found {len(episodes)} episodes")
|
||||
|
||||
# Test bulk import dates
|
||||
if episodes:
|
||||
import_dates = client.bulk_import_dates_for_series(series_id)
|
||||
print(f"Import dates for {len(import_dates)} episodes")
|
||||
else:
|
||||
print("Test series not found")
|
||||
else:
|
||||
print("❌ Could not connect to database - check environment variables")
|
||||
@@ -173,12 +173,6 @@ class NFOGuardConfig:
|
||||
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
||||
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
|
||||
|
||||
def get_season_dir_name(self, season: int) -> str:
|
||||
"""Get the directory name for a specific season, handling Season 0 as 'Specials'"""
|
||||
if season == 0:
|
||||
return "Specials"
|
||||
return self.tv_season_dir_format.format(season=season)
|
||||
|
||||
def _load_auth_settings(self) -> None:
|
||||
"""Load web interface authentication settings"""
|
||||
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
Async NFO Manager for NFOGuard
|
||||
High-performance async NFO file operations with concurrent processing
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from utils.logging import _log
|
||||
from utils.async_file_utils import (
|
||||
async_read_nfo_file,
|
||||
async_write_nfo_file,
|
||||
async_file_exists,
|
||||
async_set_file_mtime,
|
||||
async_batch_nfo_operations,
|
||||
async_concurrent_episode_processing
|
||||
)
|
||||
from utils.nfo_patterns import (
|
||||
create_basic_nfo_structure,
|
||||
extract_imdb_from_nfo_content,
|
||||
extract_dates_from_nfo,
|
||||
extract_imdb_id_from_text
|
||||
)
|
||||
from utils.validation import validate_date_string
|
||||
|
||||
|
||||
class AsyncNFOManager:
|
||||
"""Async NFO file manager with concurrent processing capabilities"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||
self.manager_brand = manager_brand
|
||||
self.debug = debug
|
||||
|
||||
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""
|
||||
Async extract IMDb ID from directory path or filename
|
||||
|
||||
Args:
|
||||
path: Path to examine
|
||||
|
||||
Returns:
|
||||
IMDb ID if found, None otherwise
|
||||
"""
|
||||
# Use the sync version since it's just string processing
|
||||
return extract_imdb_id_from_text(str(path))
|
||||
|
||||
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||
"""
|
||||
Async extract IMDb ID from NFO file content
|
||||
|
||||
Args:
|
||||
nfo_path: Path to NFO file
|
||||
|
||||
Returns:
|
||||
IMDb ID if found, None otherwise
|
||||
"""
|
||||
root = await async_read_nfo_file(nfo_path)
|
||||
if root is None:
|
||||
return None
|
||||
|
||||
return extract_imdb_from_nfo_content(root)
|
||||
|
||||
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||
"""
|
||||
Async find IMDb ID from directory name, filenames, or NFO file
|
||||
|
||||
Args:
|
||||
movie_dir: Path to movie directory
|
||||
|
||||
Returns:
|
||||
IMDb ID if found, None otherwise
|
||||
"""
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
|
||||
|
||||
# First try directory name
|
||||
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
|
||||
if imdb_id:
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory concurrently
|
||||
if await async_file_exists(movie_dir):
|
||||
try:
|
||||
from utils.async_file_utils import aiofiles
|
||||
entries = await aiofiles.os.listdir(movie_dir)
|
||||
|
||||
# Create tasks to check all files
|
||||
async def check_file(filename: str) -> Optional[str]:
|
||||
file_path = movie_dir / filename
|
||||
if await aiofiles.os.path.isfile(file_path):
|
||||
return await self.async_parse_imdb_from_path(file_path)
|
||||
return None
|
||||
|
||||
# Check all files concurrently
|
||||
results = await asyncio.gather(
|
||||
*[check_file(filename) for filename in entries],
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Find first valid IMDb ID
|
||||
for result in results:
|
||||
if isinstance(result, str) and result:
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Found IMDb ID in filename: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
|
||||
|
||||
# Finally, try NFO file content
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
|
||||
return imdb_id
|
||||
|
||||
if self.debug:
|
||||
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
|
||||
return None
|
||||
|
||||
async def async_create_movie_nfo(
|
||||
self,
|
||||
movie_dir: Path,
|
||||
imdb_id: str,
|
||||
dateadded: str,
|
||||
premiered: Optional[str] = None,
|
||||
lock_metadata: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Async create movie NFO file
|
||||
|
||||
Args:
|
||||
movie_dir: Path to movie directory
|
||||
imdb_id: IMDb ID
|
||||
dateadded: Date added
|
||||
premiered: Optional premiere date
|
||||
lock_metadata: Whether to lock metadata
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
|
||||
# Prepare dates
|
||||
dates = {"dateadded": dateadded}
|
||||
if premiered and validate_date_string(premiered):
|
||||
dates["premiered"] = premiered
|
||||
|
||||
# Create NFO structure
|
||||
root = create_basic_nfo_structure(
|
||||
media_type="movie",
|
||||
title=movie_dir.name,
|
||||
imdb_id=imdb_id,
|
||||
dates=dates,
|
||||
additional_fields={"source": self.manager_brand}
|
||||
)
|
||||
|
||||
# Write NFO file asynchronously
|
||||
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
|
||||
|
||||
if success and self.debug:
|
||||
_log("DEBUG", f"Created movie NFO: {nfo_path}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
|
||||
return False
|
||||
|
||||
async def async_create_episode_nfo(
|
||||
self,
|
||||
season_dir: Path,
|
||||
season: int,
|
||||
episode: int,
|
||||
aired: Optional[str] = None,
|
||||
dateadded: Optional[str] = None,
|
||||
source: str = "unknown",
|
||||
lock_metadata: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Async create episode NFO file
|
||||
|
||||
Args:
|
||||
season_dir: Path to season directory
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
aired: Optional air date
|
||||
dateadded: Optional date added
|
||||
source: Source of the data
|
||||
lock_metadata: Whether to lock metadata
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
|
||||
nfo_path = season_dir / nfo_filename
|
||||
|
||||
# Prepare dates
|
||||
dates = {}
|
||||
if aired and validate_date_string(aired):
|
||||
dates["aired"] = aired
|
||||
if dateadded and validate_date_string(dateadded):
|
||||
dates["dateadded"] = dateadded
|
||||
|
||||
# Create NFO structure
|
||||
root = create_basic_nfo_structure(
|
||||
media_type="episodedetails",
|
||||
title=f"S{season:02d}E{episode:02d}",
|
||||
dates=dates,
|
||||
additional_fields={
|
||||
"season": str(season),
|
||||
"episode": str(episode),
|
||||
"source": source
|
||||
}
|
||||
)
|
||||
|
||||
# Write NFO file asynchronously
|
||||
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
|
||||
|
||||
if success and self.debug:
|
||||
_log("DEBUG", f"Created episode NFO: {nfo_path}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
|
||||
return False
|
||||
|
||||
async def async_batch_create_episode_nfos(
|
||||
self,
|
||||
episode_data_list: List[Dict[str, Any]],
|
||||
max_concurrent: int = 5
|
||||
) -> List[bool]:
|
||||
"""
|
||||
Batch create multiple episode NFOs concurrently
|
||||
|
||||
Args:
|
||||
episode_data_list: List of episode data dictionaries
|
||||
max_concurrent: Maximum concurrent NFO operations
|
||||
|
||||
Returns:
|
||||
List of success/failure results
|
||||
"""
|
||||
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
|
||||
return await self.async_create_episode_nfo(
|
||||
season_dir=episode_data.get('season_dir'),
|
||||
season=episode_data.get('season'),
|
||||
episode=episode_data.get('episode'),
|
||||
aired=episode_data.get('aired'),
|
||||
dateadded=episode_data.get('dateadded'),
|
||||
source=episode_data.get('source', 'unknown'),
|
||||
lock_metadata=episode_data.get('lock_metadata', True)
|
||||
)
|
||||
|
||||
return await async_concurrent_episode_processing(
|
||||
episode_data_list,
|
||||
_create_episode_nfo,
|
||||
max_concurrent
|
||||
)
|
||||
|
||||
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
|
||||
"""
|
||||
Async set file modification time from date string
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
date_str: Date string in ISO format
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not validate_date_string(date_str):
|
||||
_log("WARNING", f"Invalid date format for mtime: {date_str}")
|
||||
return False
|
||||
|
||||
# Parse date string to timestamp
|
||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
timestamp = dt.timestamp()
|
||||
|
||||
# Set file mtime asynchronously
|
||||
success = await async_set_file_mtime(file_path, timestamp)
|
||||
|
||||
if success and self.debug:
|
||||
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
async def async_batch_set_file_mtimes(
|
||||
self,
|
||||
file_mtime_pairs: List[Tuple[Path, str]],
|
||||
max_concurrent: int = 10
|
||||
) -> List[bool]:
|
||||
"""
|
||||
Batch set file modification times concurrently
|
||||
|
||||
Args:
|
||||
file_mtime_pairs: List of (file_path, date_str) tuples
|
||||
max_concurrent: Maximum concurrent operations
|
||||
|
||||
Returns:
|
||||
List of success/failure results
|
||||
"""
|
||||
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
|
||||
return await self.async_set_file_mtime(file_path, date_str)
|
||||
|
||||
# Create tasks for all mtime operations
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
|
||||
async with semaphore:
|
||||
return await _set_single_mtime(file_path, date_str)
|
||||
|
||||
tasks = [
|
||||
_set_mtime_with_semaphore(file_path, date_str)
|
||||
for file_path, date_str in file_mtime_pairs
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
return [result if isinstance(result, bool) else False for result in results]
|
||||
|
||||
async def async_validate_nfo_integrity(
|
||||
self,
|
||||
nfo_paths: List[Path],
|
||||
max_concurrent: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Async validate integrity of multiple NFO files
|
||||
|
||||
Args:
|
||||
nfo_paths: List of NFO file paths to validate
|
||||
max_concurrent: Maximum concurrent validations
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results and statistics
|
||||
"""
|
||||
results = {
|
||||
'total_files': len(nfo_paths),
|
||||
'valid_files': 0,
|
||||
'invalid_files': 0,
|
||||
'missing_files': 0,
|
||||
'validation_errors': [],
|
||||
'file_results': {}
|
||||
}
|
||||
|
||||
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
|
||||
file_result = {
|
||||
'path': str(nfo_path),
|
||||
'exists': False,
|
||||
'valid_xml': False,
|
||||
'has_imdb_id': False,
|
||||
'has_dates': False,
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
# Check if file exists
|
||||
if not await async_file_exists(nfo_path):
|
||||
file_result['error'] = 'File does not exist'
|
||||
return file_result
|
||||
|
||||
file_result['exists'] = True
|
||||
|
||||
# Try to parse NFO
|
||||
root = await async_read_nfo_file(nfo_path)
|
||||
if root is None:
|
||||
file_result['error'] = 'Failed to parse XML'
|
||||
return file_result
|
||||
|
||||
file_result['valid_xml'] = True
|
||||
|
||||
# Check for IMDb ID
|
||||
imdb_id = extract_imdb_from_nfo_content(root)
|
||||
file_result['has_imdb_id'] = bool(imdb_id)
|
||||
|
||||
# Check for dates
|
||||
dates = extract_dates_from_nfo(root)
|
||||
file_result['has_dates'] = any(dates.values())
|
||||
|
||||
except Exception as e:
|
||||
file_result['error'] = str(e)
|
||||
|
||||
return file_result
|
||||
|
||||
# Validate all files concurrently
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
|
||||
async with semaphore:
|
||||
return await _validate_single_nfo(nfo_path)
|
||||
|
||||
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
|
||||
file_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(file_results):
|
||||
if isinstance(result, dict):
|
||||
path_str = str(nfo_paths[i])
|
||||
results['file_results'][path_str] = result
|
||||
|
||||
if not result['exists']:
|
||||
results['missing_files'] += 1
|
||||
elif result['valid_xml']:
|
||||
results['valid_files'] += 1
|
||||
else:
|
||||
results['invalid_files'] += 1
|
||||
|
||||
if result.get('error'):
|
||||
results['validation_errors'].append(f"{path_str}: {result['error']}")
|
||||
|
||||
return results
|
||||
+5
-519
@@ -108,8 +108,6 @@ class NFOGuardDatabase:
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
imdb_id VARCHAR(20) PRIMARY KEY,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
path TEXT NOT NULL,
|
||||
released DATE,
|
||||
dateadded TIMESTAMP,
|
||||
@@ -119,44 +117,6 @@ class NFOGuardDatabase:
|
||||
)
|
||||
""")
|
||||
|
||||
# Add title and year columns if they don't exist (migration for existing databases)
|
||||
cursor.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='title') THEN
|
||||
ALTER TABLE movies ADD COLUMN title TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='year') THEN
|
||||
ALTER TABLE movies ADD COLUMN year INTEGER;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Add skipped and skip_reason columns if they don't exist (migration for skip tracking)
|
||||
cursor.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='skipped') THEN
|
||||
ALTER TABLE movies ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='movies' AND column_name='skip_reason') THEN
|
||||
ALTER TABLE movies ADD COLUMN skip_reason TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='episodes' AND column_name='skipped') THEN
|
||||
ALTER TABLE episodes ADD COLUMN skipped BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='episodes' AND column_name='skip_reason') THEN
|
||||
ALTER TABLE episodes ADD COLUMN skip_reason TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Processing history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_history (
|
||||
@@ -187,48 +147,6 @@ class NFOGuardDatabase:
|
||||
)
|
||||
""")
|
||||
|
||||
# Scheduled scans table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scheduled_scans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cron_expression VARCHAR(100) NOT NULL,
|
||||
media_type VARCHAR(20) NOT NULL CHECK (media_type IN ('tv', 'movies', 'both')),
|
||||
scan_mode VARCHAR(20) NOT NULL CHECK (scan_mode IN ('smart', 'full', 'incomplete')),
|
||||
specific_paths TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_run_at TIMESTAMP,
|
||||
next_run_at TIMESTAMP,
|
||||
run_count INTEGER DEFAULT 0,
|
||||
created_by VARCHAR(100),
|
||||
updated_by VARCHAR(100)
|
||||
)
|
||||
""")
|
||||
|
||||
# Schedule execution history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schedule_executions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status VARCHAR(50) NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
media_type VARCHAR(20) NOT NULL,
|
||||
scan_mode VARCHAR(20) NOT NULL,
|
||||
items_processed INTEGER DEFAULT 0,
|
||||
items_skipped INTEGER DEFAULT 0,
|
||||
items_failed INTEGER DEFAULT 0,
|
||||
execution_time_seconds INTEGER,
|
||||
error_message TEXT,
|
||||
logs TEXT,
|
||||
triggered_by VARCHAR(100),
|
||||
FOREIGN KEY (schedule_id) REFERENCES scheduled_scans(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for PostgreSQL
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||
@@ -237,11 +155,6 @@ class NFOGuardDatabase:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_type ON missing_imdb(media_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_resolved ON missing_imdb(resolved)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_path ON missing_imdb(file_path)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_enabled ON scheduled_scans(enabled)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_next_run ON scheduled_scans(next_run_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_schedule ON schedule_executions(schedule_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_status ON schedule_executions(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_started ON schedule_executions(started_at)")
|
||||
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||
"""Insert or update series record"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -295,28 +208,25 @@ class NFOGuardDatabase:
|
||||
""", (imdb_id, path, timestamp))
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False,
|
||||
title: Optional[str] = None, year: Optional[int] = None):
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False):
|
||||
"""Insert or update movie date record"""
|
||||
import os
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, title={title}, dateadded={dateadded}, source={source}")
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.utcnow()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
|
||||
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
released = EXCLUDED.released,
|
||||
dateadded = EXCLUDED.dateadded,
|
||||
source = EXCLUDED.source,
|
||||
has_video_file = EXCLUDED.has_video_file,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (imdb_id, title, year, imdb_id, released, dateadded, source, has_video_file, timestamp))
|
||||
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp))
|
||||
|
||||
# Debug: Check what was actually saved
|
||||
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,))
|
||||
@@ -325,74 +235,6 @@ class NFOGuardDatabase:
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}")
|
||||
|
||||
def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str):
|
||||
"""Mark a movie as skipped with reason"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.utcnow()
|
||||
cursor.execute("""
|
||||
INSERT INTO movies (imdb_id, title, year, path, skipped, skip_reason, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s)
|
||||
ON CONFLICT (imdb_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
path = EXCLUDED.path,
|
||||
skipped = TRUE,
|
||||
skip_reason = EXCLUDED.skip_reason,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (imdb_id, title, year, path, reason, timestamp))
|
||||
|
||||
def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str):
|
||||
"""Mark an episode as skipped with reason"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.utcnow()
|
||||
cursor.execute("""
|
||||
INSERT INTO episodes (imdb_id, season, episode, skipped, skip_reason, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, TRUE, %s, FALSE, %s)
|
||||
ON CONFLICT (imdb_id, season, episode) DO UPDATE SET
|
||||
skipped = TRUE,
|
||||
skip_reason = EXCLUDED.skip_reason,
|
||||
last_updated = EXCLUDED.last_updated
|
||||
""", (imdb_id, season, episode, reason, timestamp))
|
||||
|
||||
def clear_movie_skipped(self, imdb_id: str):
|
||||
"""Clear skipped flag for a movie"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE movies SET skipped = FALSE, skip_reason = NULL
|
||||
WHERE imdb_id = %s
|
||||
""", (imdb_id,))
|
||||
|
||||
def clear_episode_skipped(self, imdb_id: str, season: int, episode: int):
|
||||
"""Clear skipped flag for an episode"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE episodes SET skipped = FALSE, skip_reason = NULL
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
""", (imdb_id, season, episode))
|
||||
|
||||
def get_skipped_counts(self) -> Dict:
|
||||
"""Get counts of skipped movies and episodes"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Count skipped movies
|
||||
cursor.execute("SELECT COUNT(*) as count FROM movies WHERE skipped = TRUE")
|
||||
skipped_movies = cursor.fetchone()['count']
|
||||
|
||||
# Count skipped episodes
|
||||
cursor.execute("SELECT COUNT(*) as count FROM episodes WHERE skipped = TRUE")
|
||||
skipped_episodes = cursor.fetchone()['count']
|
||||
|
||||
return {
|
||||
'movies': skipped_movies,
|
||||
'episodes': skipped_episodes,
|
||||
'total': skipped_movies + skipped_episodes
|
||||
}
|
||||
|
||||
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
|
||||
"""Get all episodes for a series"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -437,81 +279,6 @@ class NFOGuardDatabase:
|
||||
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
|
||||
json.dumps(details) if details else None))
|
||||
|
||||
def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
|
||||
"""Migrate a movie from placeholder IMDb ID to real IMDb ID"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if old record exists
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (old_imdb_id,))
|
||||
old_record = cursor.fetchone()
|
||||
if not old_record:
|
||||
return False
|
||||
|
||||
old_data = dict(old_record)
|
||||
|
||||
# Check if new IMDb ID already exists
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (new_imdb_id,))
|
||||
if cursor.fetchone():
|
||||
# New IMDb ID already exists, just delete the old one
|
||||
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
# Create new record with correct IMDb ID
|
||||
cursor.execute("""
|
||||
INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source,
|
||||
has_video_file, last_updated, skipped, skip_reason)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (new_imdb_id, old_data.get('title'), old_data.get('year'),
|
||||
old_data.get('path'), old_data.get('released'), old_data.get('dateadded'),
|
||||
old_data.get('source'), old_data.get('has_video_file'),
|
||||
datetime.utcnow(), False, None)) # Clear skipped status
|
||||
|
||||
# Delete old placeholder record
|
||||
cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool:
|
||||
"""Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if old series exists
|
||||
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (old_imdb_id,))
|
||||
old_series = cursor.fetchone()
|
||||
if not old_series:
|
||||
return False
|
||||
|
||||
old_series_data = dict(old_series)
|
||||
|
||||
# Check if new series IMDb ID already exists
|
||||
cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (new_imdb_id,))
|
||||
if cursor.fetchone():
|
||||
# New series already exists, migrate episodes and delete old series
|
||||
cursor.execute("""
|
||||
UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s
|
||||
""", (new_imdb_id, old_imdb_id))
|
||||
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
# Create new series record
|
||||
cursor.execute("""
|
||||
INSERT INTO series (imdb_id, path, last_updated, metadata)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (new_imdb_id, old_series_data.get('path'),
|
||||
datetime.utcnow(), old_series_data.get('metadata')))
|
||||
|
||||
# Migrate all episodes to new series IMDb ID and clear skipped status
|
||||
cursor.execute("""
|
||||
UPDATE episodes
|
||||
SET imdb_id = %s, skipped = FALSE, skip_reason = NULL
|
||||
WHERE imdb_id = %s
|
||||
""", (new_imdb_id, old_imdb_id))
|
||||
|
||||
# Delete old placeholder series
|
||||
cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,))
|
||||
return True
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -718,67 +485,6 @@ class NFOGuardDatabase:
|
||||
|
||||
return deleted_count > 0
|
||||
|
||||
def update_movie_file_info(self, imdb_id: str, path: str, has_video_file: bool = True) -> bool:
|
||||
"""
|
||||
Update path and video file status for an existing movie
|
||||
Used when population finds a video file for a manually-added movie
|
||||
|
||||
Args:
|
||||
imdb_id: Movie IMDb ID
|
||||
path: File path to the video file
|
||||
has_video_file: Whether a video file exists (default True)
|
||||
|
||||
Returns:
|
||||
True if movie was updated, False if not found
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE movies
|
||||
SET path = %s,
|
||||
has_video_file = %s,
|
||||
last_updated = %s
|
||||
WHERE imdb_id = %s
|
||||
""", (path, has_video_file, datetime.utcnow(), imdb_id))
|
||||
|
||||
updated_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return updated_count > 0
|
||||
|
||||
def update_episode_file_info(self, imdb_id: str, season: int, episode: int,
|
||||
path: str, has_video_file: bool = True) -> bool:
|
||||
"""
|
||||
Update path and video file status for an existing episode
|
||||
Used when population finds a video file for a manually-added episode
|
||||
|
||||
Args:
|
||||
imdb_id: Series IMDb ID
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
path: File path to the video file
|
||||
has_video_file: Whether a video file exists (default True)
|
||||
|
||||
Returns:
|
||||
True if episode was updated, False if not found
|
||||
"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE episodes
|
||||
SET path = %s,
|
||||
has_video_file = %s,
|
||||
last_updated = %s
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
""", (path, has_video_file, datetime.utcnow(), imdb_id, season, episode))
|
||||
|
||||
updated_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return updated_count > 0
|
||||
|
||||
def delete_orphaned_movies(self) -> List[Dict]:
|
||||
"""
|
||||
Find and delete movies that don't have corresponding video files on disk
|
||||
@@ -967,226 +673,6 @@ class NFOGuardDatabase:
|
||||
|
||||
return deleted_count > 0
|
||||
|
||||
# Scheduled Scans Methods
|
||||
|
||||
def create_scheduled_scan(self, name: str, description: str, cron_expression: str,
|
||||
media_type: str, scan_mode: str, specific_paths: str = None,
|
||||
enabled: bool = True, created_by: str = None) -> int:
|
||||
"""Create a new scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO scheduled_scans
|
||||
(name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by))
|
||||
|
||||
return cursor.fetchone()['id']
|
||||
|
||||
def get_scheduled_scans(self, enabled_only: bool = False) -> List[Dict]:
|
||||
"""Get all scheduled scans"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM scheduled_scans"
|
||||
if enabled_only:
|
||||
query += " WHERE enabled = TRUE"
|
||||
query += " ORDER BY name"
|
||||
|
||||
cursor.execute(query)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_scheduled_scan(self, scan_id: int) -> Optional[Dict]:
|
||||
"""Get a specific scheduled scan by ID"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM scheduled_scans WHERE id = %s", (scan_id,))
|
||||
return cursor.fetchone()
|
||||
|
||||
def update_scheduled_scan(self, scan_id: int, name: str = None, description: str = None,
|
||||
cron_expression: str = None, media_type: str = None,
|
||||
scan_mode: str = None, specific_paths: str = None,
|
||||
enabled: bool = None, updated_by: str = None) -> bool:
|
||||
"""Update a scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if name is not None:
|
||||
updates.append("name = %s")
|
||||
params.append(name)
|
||||
if description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(description)
|
||||
if cron_expression is not None:
|
||||
updates.append("cron_expression = %s")
|
||||
params.append(cron_expression)
|
||||
if media_type is not None:
|
||||
updates.append("media_type = %s")
|
||||
params.append(media_type)
|
||||
if scan_mode is not None:
|
||||
updates.append("scan_mode = %s")
|
||||
params.append(scan_mode)
|
||||
if specific_paths is not None:
|
||||
updates.append("specific_paths = %s")
|
||||
params.append(specific_paths)
|
||||
if enabled is not None:
|
||||
updates.append("enabled = %s")
|
||||
params.append(enabled)
|
||||
if updated_by is not None:
|
||||
updates.append("updated_by = %s")
|
||||
params.append(updated_by)
|
||||
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(scan_id)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
query = f"UPDATE scheduled_scans SET {', '.join(updates)} WHERE id = %s"
|
||||
cursor.execute(query, params)
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_scheduled_scan(self, scan_id: int) -> bool:
|
||||
"""Delete a scheduled scan and its execution history"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM scheduled_scans WHERE id = %s", (scan_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_scan_next_run(self, scan_id: int, next_run_at: datetime) -> bool:
|
||||
"""Update the next run time for a scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE scheduled_scans
|
||||
SET next_run_at = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (next_run_at, scan_id))
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_scan_last_run(self, scan_id: int, last_run_at: datetime = None) -> bool:
|
||||
"""Update the last run time and increment run count for a scheduled scan"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if last_run_at is None:
|
||||
last_run_at = datetime.utcnow()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE scheduled_scans
|
||||
SET last_run_at = %s, run_count = run_count + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (last_run_at, scan_id))
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# Schedule Execution Methods
|
||||
|
||||
def create_schedule_execution(self, schedule_id: int, media_type: str, scan_mode: str,
|
||||
triggered_by: str = None) -> int:
|
||||
"""Create a new schedule execution record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO schedule_executions
|
||||
(schedule_id, status, media_type, scan_mode, triggered_by)
|
||||
VALUES (%s, 'running', %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (schedule_id, media_type, scan_mode, triggered_by))
|
||||
|
||||
return cursor.fetchone()['id']
|
||||
|
||||
def update_schedule_execution(self, execution_id: int, status: str = None,
|
||||
items_processed: int = None, items_skipped: int = None,
|
||||
items_failed: int = None, error_message: str = None,
|
||||
logs: str = None) -> bool:
|
||||
"""Update a schedule execution record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status is not None:
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
if status in ['completed', 'failed', 'cancelled']:
|
||||
updates.append("completed_at = CURRENT_TIMESTAMP")
|
||||
updates.append("execution_time_seconds = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at))")
|
||||
|
||||
if items_processed is not None:
|
||||
updates.append("items_processed = %s")
|
||||
params.append(items_processed)
|
||||
if items_skipped is not None:
|
||||
updates.append("items_skipped = %s")
|
||||
params.append(items_skipped)
|
||||
if items_failed is not None:
|
||||
updates.append("items_failed = %s")
|
||||
params.append(items_failed)
|
||||
if error_message is not None:
|
||||
updates.append("error_message = %s")
|
||||
params.append(error_message)
|
||||
if logs is not None:
|
||||
updates.append("logs = %s")
|
||||
params.append(logs)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(execution_id)
|
||||
query = f"UPDATE schedule_executions SET {', '.join(updates)} WHERE id = %s"
|
||||
cursor.execute(query, params)
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_schedule_executions(self, schedule_id: int = None, limit: int = 50) -> List[Dict]:
|
||||
"""Get schedule execution history"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT se.*, ss.name as schedule_name
|
||||
FROM schedule_executions se
|
||||
JOIN scheduled_scans ss ON se.schedule_id = ss.id
|
||||
"""
|
||||
params = []
|
||||
|
||||
if schedule_id is not None:
|
||||
query += " WHERE se.schedule_id = %s"
|
||||
params.append(schedule_id)
|
||||
|
||||
query += " ORDER BY se.started_at DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_running_executions(self) -> List[Dict]:
|
||||
"""Get currently running schedule executions"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT se.*, ss.name as schedule_name
|
||||
FROM schedule_executions se
|
||||
JOIN scheduled_scans ss ON se.schedule_id = ss.id
|
||||
WHERE se.status = 'running'
|
||||
ORDER BY se.started_at DESC
|
||||
""")
|
||||
|
||||
return cursor.fetchall()
|
||||
|
||||
def close(self):
|
||||
"""Close all database connections"""
|
||||
if hasattr(self._local, 'connection'):
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database Populator for NFOGuard
|
||||
Bulk populates the NFOGuard database from Radarr/Sonarr
|
||||
Phase 4: Replace NFO-based initial population with direct DB/API queries
|
||||
"""
|
||||
import time
|
||||
import hashlib
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import parse_imdb_from_path
|
||||
|
||||
|
||||
class DatabasePopulator:
|
||||
"""Populates NFOGuard database from Radarr/Sonarr sources"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, radarr_client: RadarrClient, sonarr_client: SonarrClient):
|
||||
self.db = db
|
||||
self.radarr = radarr_client
|
||||
self.sonarr = sonarr_client
|
||||
|
||||
def populate_movies(self) -> Dict[str, any]:
|
||||
"""
|
||||
Populate movies from Radarr database/API
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics: {
|
||||
'total': int,
|
||||
'added': int,
|
||||
'updated': int,
|
||||
'skipped': int,
|
||||
'errors': int,
|
||||
'duration': float
|
||||
}
|
||||
"""
|
||||
_log("INFO", "Starting movie population from Radarr")
|
||||
start_time = time.time()
|
||||
|
||||
stats = {
|
||||
'total': 0,
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
'skipped': 0,
|
||||
'errors': 0,
|
||||
'duration': 0.0,
|
||||
'skipped_items': [] # Track what was skipped and why
|
||||
}
|
||||
|
||||
try:
|
||||
# Get all movies from Radarr database
|
||||
if not hasattr(self.radarr, 'db_client') or not self.radarr.db_client:
|
||||
_log("ERROR", "Radarr database client not available - cannot populate movies")
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
movies = self.radarr.db_client.get_all_movies()
|
||||
if not movies:
|
||||
_log("WARNING", "No movies found in Radarr database")
|
||||
return stats
|
||||
|
||||
stats['total'] = len(movies)
|
||||
_log("INFO", f"Found {stats['total']} movies in Radarr")
|
||||
|
||||
# Process each movie
|
||||
for movie in movies:
|
||||
try:
|
||||
# Get movie path first (we'll need it for IMDb extraction)
|
||||
path = movie.get('path', '')
|
||||
|
||||
# Try to get IMDb ID from Radarr database
|
||||
imdb_id = movie.get('imdb_id')
|
||||
|
||||
# If not in database, try extracting from directory/filename
|
||||
if not imdb_id and path:
|
||||
imdb_id = parse_imdb_from_path(Path(path))
|
||||
if imdb_id:
|
||||
_log("DEBUG", f"Extracted IMDb ID {imdb_id} from path for: {movie.get('title')}")
|
||||
|
||||
if not imdb_id:
|
||||
# Generate placeholder IMDb ID using hash of path
|
||||
path_hash = hashlib.md5(path.encode()).hexdigest()[:12]
|
||||
imdb_id = f"missing-{path_hash}"
|
||||
skip_reason = 'No IMDb ID found'
|
||||
skip_info = {
|
||||
'title': movie.get('title', 'Unknown'),
|
||||
'year': movie.get('year'),
|
||||
'imdb_id': imdb_id,
|
||||
'path': path,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
_log("DEBUG", f"Movie without IMDb ID: {movie.get('title')} (path: {path}), using placeholder {imdb_id}")
|
||||
|
||||
# Mark as skipped in database with placeholder IMDb ID
|
||||
self.db.mark_movie_skipped(
|
||||
imdb_id=imdb_id,
|
||||
title=movie.get('title', 'Unknown'),
|
||||
year=movie.get('year', 0),
|
||||
path=path,
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Check if movie already exists in database
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
if existing and existing.get('dateadded'):
|
||||
# Already in database - update file path and video status if needed
|
||||
existing_path = existing.get('path')
|
||||
if not existing_path or existing_path == 'unknown' or existing_path != path:
|
||||
_log("INFO", f"Movie {imdb_id} exists but updating file info: {path}")
|
||||
self.db.update_movie_file_info(imdb_id, path, has_video_file=True)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='movie',
|
||||
event_type='file_info_update',
|
||||
details={'path': path}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
|
||||
|
||||
stats['updated'] += 1
|
||||
else:
|
||||
_log("DEBUG", f"Movie {imdb_id} already in database with correct path, skipping")
|
||||
continue
|
||||
|
||||
# Get release date
|
||||
released = None
|
||||
if movie.get('digital_release'):
|
||||
released = movie.get('digital_release')
|
||||
source_type = 'radarr:digital'
|
||||
elif movie.get('physical_release'):
|
||||
released = movie.get('physical_release')
|
||||
source_type = 'radarr:physical'
|
||||
elif movie.get('in_cinemas'):
|
||||
released = movie.get('in_cinemas')
|
||||
source_type = 'radarr:theatrical'
|
||||
else:
|
||||
source_type = 'radarr:unknown'
|
||||
|
||||
# Get import date from Radarr history using Radarr's internal movie ID
|
||||
radarr_movie_id = movie.get('id')
|
||||
if radarr_movie_id:
|
||||
# get_movie_import_date returns tuple (date, source)
|
||||
import_date, import_source = self.radarr.get_movie_import_date(radarr_movie_id)
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = import_source
|
||||
elif released:
|
||||
# Use release date as fallback
|
||||
dateadded = released
|
||||
source = f'{source_type}_fallback'
|
||||
else:
|
||||
skip_reason = 'No import date in Radarr history and no release dates available'
|
||||
skip_info = {
|
||||
'title': movie.get('title', 'Unknown'),
|
||||
'year': movie.get('year'),
|
||||
'imdb_id': imdb_id,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
|
||||
|
||||
# Mark as skipped in database for troubleshooting
|
||||
self.db.mark_movie_skipped(
|
||||
imdb_id=imdb_id,
|
||||
title=movie.get('title', 'Unknown'),
|
||||
year=movie.get('year', 0),
|
||||
path=path or 'unknown',
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
elif released:
|
||||
# No Radarr ID, use release date
|
||||
dateadded = released
|
||||
source = f'{source_type}_fallback'
|
||||
else:
|
||||
skip_reason = 'No Radarr movie ID and no release dates available'
|
||||
skip_info = {
|
||||
'title': movie.get('title', 'Unknown'),
|
||||
'year': movie.get('year'),
|
||||
'imdb_id': imdb_id,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
_log("DEBUG", f"No date available for movie {imdb_id}, skipping")
|
||||
|
||||
# Mark as skipped in database for troubleshooting
|
||||
self.db.mark_movie_skipped(
|
||||
imdb_id=imdb_id,
|
||||
title=movie.get('title', 'Unknown'),
|
||||
year=movie.get('year', 0),
|
||||
path=path or 'unknown',
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Insert into database with title and year
|
||||
title = movie.get('title')
|
||||
year = movie.get('year')
|
||||
self.db.upsert_movie_dates(
|
||||
imdb_id, released, dateadded, source,
|
||||
has_video_file=True, title=title, year=year
|
||||
)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='movie',
|
||||
event_type='database_population',
|
||||
details={'source': source, 'title': title}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id}: {e}")
|
||||
|
||||
stats['added'] += 1
|
||||
_log("DEBUG", f"Added movie {imdb_id}: {title} ({year}) (source: {source})")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing movie {movie.get('title', 'unknown')}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error during movie population: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
stats['duration'] = time.time() - start_time
|
||||
_log("INFO", f"Movie population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
|
||||
|
||||
# Log details of skipped items
|
||||
if stats['skipped_items']:
|
||||
_log("INFO", f"Skipped items details ({len(stats['skipped_items'])} total):")
|
||||
for item in stats['skipped_items']:
|
||||
_log("INFO", f" - {item['title']} ({item.get('year', 'N/A')}) [{item.get('imdb_id', 'No IMDb')}]: {item['reason']}")
|
||||
|
||||
return stats
|
||||
|
||||
def populate_tv_episodes(self) -> Dict[str, any]:
|
||||
"""
|
||||
Populate TV episodes from Sonarr API
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics: {
|
||||
'total_series': int,
|
||||
'total_episodes': int,
|
||||
'added': int,
|
||||
'updated': int,
|
||||
'skipped': int,
|
||||
'errors': int,
|
||||
'duration': float
|
||||
}
|
||||
"""
|
||||
_log("INFO", "Starting TV episode population from Sonarr")
|
||||
start_time = time.time()
|
||||
|
||||
stats = {
|
||||
'total_series': 0,
|
||||
'total_episodes': 0,
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
'skipped': 0,
|
||||
'errors': 0,
|
||||
'duration': 0.0,
|
||||
'skipped_items': [] # Track what was skipped and why
|
||||
}
|
||||
|
||||
try:
|
||||
# Get all series from Sonarr
|
||||
all_series = self.sonarr.get_all_series()
|
||||
if not all_series:
|
||||
_log("WARNING", "No series found in Sonarr")
|
||||
return stats
|
||||
|
||||
stats['total_series'] = len(all_series)
|
||||
_log("INFO", f"Found {stats['total_series']} series in Sonarr")
|
||||
|
||||
# Process each series
|
||||
for series in all_series:
|
||||
try:
|
||||
imdb_id = series.get('imdbId')
|
||||
series_id = series.get('id')
|
||||
series_path = series.get('path', '')
|
||||
series_title = series.get('title', 'Unknown')
|
||||
|
||||
if not imdb_id:
|
||||
# Generate placeholder IMDb ID using hash of path
|
||||
path_hash = hashlib.md5(series_path.encode()).hexdigest()[:12]
|
||||
imdb_id = f"missing-{path_hash}"
|
||||
_log("DEBUG", f"Series without IMDb ID: {series_title} (path: {series_path}), using placeholder {imdb_id}")
|
||||
|
||||
# Update series record
|
||||
self.db.upsert_series(imdb_id, series_path)
|
||||
|
||||
# Try high-performance database bulk query first
|
||||
sonarr_db = getattr(self.sonarr, 'db_client', None)
|
||||
bulk_import_dates = {}
|
||||
|
||||
if sonarr_db:
|
||||
try:
|
||||
_log("DEBUG", f"Using DB bulk query for {series_title}")
|
||||
bulk_import_dates = sonarr_db.bulk_import_dates_for_series(series_id)
|
||||
_log("DEBUG", f"✅ Got {len(bulk_import_dates)} import dates from DB for {series_title}")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"DB bulk query failed for {series_title}, falling back to API: {e}")
|
||||
|
||||
# Get all episodes for this series
|
||||
episodes = self.sonarr.episodes_for_series(series_id)
|
||||
if not episodes:
|
||||
continue
|
||||
|
||||
_log("DEBUG", f"Processing {len(episodes)} episodes for {series_title}")
|
||||
|
||||
# Process each episode
|
||||
for episode in episodes:
|
||||
try:
|
||||
season_num = episode.get('seasonNumber', 0)
|
||||
episode_num = episode.get('episodeNumber', 0)
|
||||
episode_title = episode.get('title', 'Unknown')
|
||||
|
||||
if season_num < 0 or episode_num <= 0:
|
||||
continue
|
||||
|
||||
stats['total_episodes'] += 1
|
||||
|
||||
# Check if episode already exists
|
||||
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if existing and existing.get('dateadded'):
|
||||
# Already in database - update file path and video status if needed
|
||||
existing_path = existing.get('path')
|
||||
episode_path = episode.get('path', 'unknown')
|
||||
if not existing_path or existing_path == 'unknown' or existing_path != episode_path:
|
||||
_log("INFO", f"Episode {imdb_id} S{season_num:02d}E{episode_num:02d} exists but updating file info: {episode_path}")
|
||||
self.db.update_episode_file_info(imdb_id, season_num, episode_num, episode_path, has_video_file=True)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='episode',
|
||||
event_type='file_info_update',
|
||||
details={'season': season_num, 'episode': episode_num, 'path': episode_path}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
stats['updated'] += 1
|
||||
continue
|
||||
|
||||
# Only process episodes that have video files
|
||||
has_file = episode.get('hasFile', False)
|
||||
if not has_file:
|
||||
# No video file - skip silently (intentionally filtered)
|
||||
continue
|
||||
|
||||
# Get air date
|
||||
aired = episode.get('airDate')
|
||||
|
||||
# Get import date
|
||||
dateadded = None
|
||||
source = None
|
||||
|
||||
# Try bulk DB result first
|
||||
if (season_num, episode_num) in bulk_import_dates:
|
||||
dateadded, source = bulk_import_dates[(season_num, episode_num)]
|
||||
# Fall back to API query
|
||||
else:
|
||||
episode_id = episode.get('id')
|
||||
if episode_id:
|
||||
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = 'sonarr:api.import_history'
|
||||
|
||||
# Fallback to air date if no import date
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
source = 'sonarr:aired_fallback'
|
||||
elif not dateadded:
|
||||
# No date available
|
||||
skip_reason = 'No import date from Sonarr history and no air date available'
|
||||
skip_info = {
|
||||
'title': series_title,
|
||||
'episode_title': episode_title,
|
||||
'season': season_num,
|
||||
'episode': episode_num,
|
||||
'reason': skip_reason
|
||||
}
|
||||
stats['skipped_items'].append(skip_info)
|
||||
|
||||
# Mark as skipped in database for troubleshooting
|
||||
self.db.mark_episode_skipped(
|
||||
imdb_id=imdb_id,
|
||||
season=season_num,
|
||||
episode=episode_num,
|
||||
reason=skip_reason
|
||||
)
|
||||
stats['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Insert into database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, has_file)
|
||||
|
||||
# Add to processing history
|
||||
try:
|
||||
self.db.add_processing_history(
|
||||
imdb_id=imdb_id,
|
||||
media_type='episode',
|
||||
event_type='database_population',
|
||||
details={'season': season_num, 'episode': episode_num, 'source': source, 'title': episode_title}
|
||||
)
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Failed to add processing history for {imdb_id} S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
stats['added'] += 1
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing episode S{season_num:02d}E{episode_num:02d} of {series_title}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing series {series.get('title', 'unknown')}: {e}")
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error during TV episode population: {e}")
|
||||
stats['errors'] += 1
|
||||
|
||||
stats['duration'] = time.time() - start_time
|
||||
_log("INFO", f"TV episode population complete: {stats['added']} added, {stats['skipped']} skipped, {stats['errors']} errors in {stats['duration']:.2f}s")
|
||||
|
||||
# Log details of skipped items
|
||||
if stats['skipped_items']:
|
||||
_log("INFO", f"Skipped episodes details ({len(stats['skipped_items'])} total):")
|
||||
for item in stats['skipped_items'][:20]: # Only log first 20 to avoid spam
|
||||
_log("INFO", f" - {item['title']} S{str(item['season']).zfill(2)}E{str(item['episode']).zfill(2)} ({item.get('episode_title', 'Unknown')}): {item['reason']}")
|
||||
if len(stats['skipped_items']) > 20:
|
||||
_log("INFO", f" ... and {len(stats['skipped_items']) - 20} more (see web interface for full list)")
|
||||
|
||||
return stats
|
||||
|
||||
def populate_all(self) -> Dict[str, any]:
|
||||
"""
|
||||
Populate both movies and TV episodes
|
||||
|
||||
Returns:
|
||||
Combined statistics dictionary
|
||||
"""
|
||||
_log("INFO", "Starting full database population")
|
||||
start_time = time.time()
|
||||
|
||||
movie_stats = self.populate_movies()
|
||||
tv_stats = self.populate_tv_episodes()
|
||||
|
||||
combined_stats = {
|
||||
'movies': movie_stats,
|
||||
'tv': tv_stats,
|
||||
'total_duration': time.time() - start_time
|
||||
}
|
||||
|
||||
_log("INFO", f"Full database population complete in {combined_stats['total_duration']:.2f}s")
|
||||
return combined_stats
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
|
||||
Core principle: NFO filenames should match video filenames
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
import re
|
||||
from .logging import _log
|
||||
|
||||
|
||||
class EpisodeNFOManager:
|
||||
"""Manages episode NFO files with video filename matching"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard"):
|
||||
self.manager_brand = manager_brand
|
||||
|
||||
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
|
||||
"""Find all video files in season directory, grouped by (season, episode)"""
|
||||
if not season_dir.exists():
|
||||
_log("DEBUG", f"Season directory does not exist: {season_dir}")
|
||||
return {}
|
||||
|
||||
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
|
||||
episodes = {}
|
||||
|
||||
_log("DEBUG", f"Scanning video files in: {season_dir}")
|
||||
for video_file in season_dir.iterdir():
|
||||
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
|
||||
if (video_file.is_file() and
|
||||
video_file.suffix.lower() in video_extensions):
|
||||
|
||||
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
|
||||
if episode_info:
|
||||
season_num, episode_num = episode_info
|
||||
key = (season_num, episode_num)
|
||||
if key not in episodes:
|
||||
episodes[key] = []
|
||||
episodes[key].append(video_file)
|
||||
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d} → {video_file.name}")
|
||||
|
||||
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
|
||||
return episodes
|
||||
|
||||
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||
"""Extract season and episode numbers from filename"""
|
||||
# Try S##E## format first (most common)
|
||||
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
# Try ##x## format
|
||||
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
return None
|
||||
|
||||
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||
"""Find existing NFO file for episode (prefer video-matching filename)"""
|
||||
if not season_dir.exists():
|
||||
return None
|
||||
|
||||
# First, look for NFO files that match video filenames
|
||||
video_files = self.find_video_files_for_season(season_dir)
|
||||
key = (season_num, episode_num)
|
||||
|
||||
if key in video_files:
|
||||
for video_file in video_files[key]:
|
||||
potential_nfo = season_dir / f"{video_file.stem}.nfo"
|
||||
if potential_nfo.exists():
|
||||
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
|
||||
return potential_nfo
|
||||
|
||||
# Fallback: look for short name NFO
|
||||
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
if short_nfo.exists():
|
||||
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
|
||||
return short_nfo
|
||||
|
||||
# Last resort: search all NFO files for matching season/episode data
|
||||
for nfo_file in season_dir.glob("*.nfo"):
|
||||
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
|
||||
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
|
||||
return nfo_file
|
||||
|
||||
return None
|
||||
|
||||
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
|
||||
"""Check if NFO file contains the specified season/episode"""
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag == "episodedetails":
|
||||
season_elem = root.find("season")
|
||||
episode_elem = root.find("episode")
|
||||
|
||||
if (season_elem is not None and episode_elem is not None and
|
||||
season_elem.text and episode_elem.text):
|
||||
try:
|
||||
file_season = int(season_elem.text)
|
||||
file_episode = int(episode_elem.text)
|
||||
return file_season == season_num and file_episode == episode_num
|
||||
except ValueError:
|
||||
pass
|
||||
except (ET.ParseError, Exception):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
|
||||
"""Get the target NFO path (prefer video filename, fallback to short name)"""
|
||||
video_files = self.find_video_files_for_season(season_dir)
|
||||
key = (season_num, episode_num)
|
||||
|
||||
if key in video_files and video_files[key]:
|
||||
# Use the first video file found (handle multiple files gracefully)
|
||||
video_file = video_files[key][0]
|
||||
target_nfo = season_dir / f"{video_file.stem}.nfo"
|
||||
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
|
||||
return target_nfo
|
||||
else:
|
||||
# Fallback to short name if no video file found
|
||||
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
|
||||
return target_nfo
|
||||
|
||||
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
|
||||
"""If short-name NFO exists, rename it to match video filename"""
|
||||
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
|
||||
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
|
||||
|
||||
# If we already have the right filename, nothing to do
|
||||
if existing_nfo and existing_nfo == target_nfo:
|
||||
return True
|
||||
|
||||
# If we have an NFO but it doesn't match target, rename it
|
||||
if existing_nfo and existing_nfo != target_nfo:
|
||||
try:
|
||||
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
|
||||
existing_nfo.rename(target_nfo)
|
||||
return True
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to rename NFO: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
|
||||
"""Create or update episode NFO with video filename matching"""
|
||||
|
||||
# Get the target NFO path (matching video filename)
|
||||
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
|
||||
|
||||
# Migrate existing NFO if needed
|
||||
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
|
||||
|
||||
try:
|
||||
# Load existing NFO if it exists
|
||||
episode_elem = None
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
episode_elem = tree.getroot()
|
||||
|
||||
if episode_elem.tag != "episodedetails":
|
||||
raise ValueError("Root element is not <episodedetails>")
|
||||
|
||||
# Remove NFOGuard-managed fields (we'll re-add them)
|
||||
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
|
||||
existing = episode_elem.find(tag)
|
||||
if existing is not None:
|
||||
episode_elem.remove(existing)
|
||||
|
||||
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
|
||||
episode_elem = None
|
||||
|
||||
# Create new structure if needed
|
||||
if episode_elem is None:
|
||||
episode_elem = ET.Element("episodedetails")
|
||||
|
||||
# Add title if provided and not already present
|
||||
if title and not episode_elem.find("title"):
|
||||
title_elem = ET.SubElement(episode_elem, "title")
|
||||
title_elem.text = title
|
||||
|
||||
# Add plot if provided and not already present
|
||||
if plot and not episode_elem.find("plot"):
|
||||
plot_elem = ET.SubElement(episode_elem, "plot")
|
||||
plot_elem.text = plot
|
||||
|
||||
# Add NFOGuard fields at the end
|
||||
season_elem = ET.SubElement(episode_elem, "season")
|
||||
season_elem.text = str(season_num)
|
||||
|
||||
episode_num_elem = ET.SubElement(episode_elem, "episode")
|
||||
episode_num_elem.text = str(episode_num)
|
||||
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode_elem, "aired")
|
||||
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
|
||||
# Also add premiered for compatibility
|
||||
premiered_elem = ET.SubElement(episode_elem, "premiered")
|
||||
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
|
||||
# Add lockdata
|
||||
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
|
||||
lockdata_elem.text = "true"
|
||||
|
||||
# Add comment with source at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode_elem.append(comment)
|
||||
|
||||
# Write the NFO file
|
||||
tree = ET.ElementTree(episode_elem)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
|
||||
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
|
||||
return False
|
||||
|
||||
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract NFOGuard-managed data from existing NFO"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag != "episodedetails":
|
||||
return None
|
||||
|
||||
# Look for NFOGuard fields
|
||||
dateadded_elem = root.find("dateadded")
|
||||
aired_elem = root.find("aired")
|
||||
lockdata_elem = root.find("lockdata")
|
||||
|
||||
# Only consider it NFOGuard-managed if it has dateadded and lockdata
|
||||
if (dateadded_elem is not None and dateadded_elem.text and
|
||||
lockdata_elem is not None and lockdata_elem.text == "true"):
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded_elem.text.strip(),
|
||||
"source": "existing_nfo"
|
||||
}
|
||||
|
||||
if aired_elem is not None and aired_elem.text:
|
||||
result["aired"] = aired_elem.text.strip()
|
||||
|
||||
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,838 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFO Manager for creating and managing metadata files
|
||||
Handles NFO creation for movies, TV shows, seasons, and episodes
|
||||
"""
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import re
|
||||
|
||||
|
||||
class NFOManager:
|
||||
"""Manages NFO file creation and updates"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||
self.manager_brand = manager_brand
|
||||
self.debug = debug
|
||||
|
||||
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from directory path or filename"""
|
||||
# Look for various IMDb patterns in both directory and file names
|
||||
path_str = str(path).lower()
|
||||
|
||||
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try standalone [ttXXXXXXX] format in brackets
|
||||
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None, shutdown_event=None) -> Optional[str]:
|
||||
"""
|
||||
Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files
|
||||
|
||||
1. First try to parse IMDb ID from directory path/name
|
||||
2. If not found, scan NFO files in directory for Sonarr series ID
|
||||
3. Use Sonarr API to lookup series and extract IMDb ID
|
||||
"""
|
||||
# Primary: Try directory name first
|
||||
imdb_id = self.parse_imdb_from_path(path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Check for shutdown signal before expensive operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Fallback: Check NFO files for Sonarr series ID
|
||||
if not sonarr_client or not sonarr_client.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Look for episode NFO files in the directory (and subdirectories)
|
||||
nfo_files = []
|
||||
if path.is_dir():
|
||||
# Check current directory and season subdirectories
|
||||
nfo_files.extend(list(path.glob("*.nfo")))
|
||||
for subdir in path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.lower().startswith('season'):
|
||||
nfo_files.extend(list(subdir.glob("*.nfo")))
|
||||
|
||||
# Extract Sonarr series ID from any NFO file
|
||||
for nfo_file in nfo_files:
|
||||
# Check for shutdown signal during NFO processing
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for Sonarr series ID
|
||||
for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'):
|
||||
sonarr_id = uniqueid.text
|
||||
if sonarr_id and sonarr_id.isdigit():
|
||||
# Check for shutdown signal before API call
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Look up series in Sonarr to get IMDb ID
|
||||
series_data = sonarr_client.get_series_by_id(int(sonarr_id))
|
||||
if series_data:
|
||||
imdb_id = series_data.get('imdbId')
|
||||
if imdb_id:
|
||||
return imdb_id.replace('tt', '') if imdb_id.startswith('tt') else imdb_id
|
||||
|
||||
except Exception as e:
|
||||
continue # Skip this NFO file and try the next one
|
||||
|
||||
except Exception as e:
|
||||
pass # Fallback failed, return None
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from NFO file content"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
root = self._parse_nfo_with_tolerance(nfo_path)
|
||||
|
||||
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
|
||||
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
|
||||
if imdb_uniqueid is not None and imdb_uniqueid.text:
|
||||
imdb_id = imdb_uniqueid.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Check for legacy <imdbid>ttXXXXXX</imdbid>
|
||||
imdbid_elem = root.find('.//imdbid')
|
||||
if imdbid_elem is not None and imdbid_elem.text:
|
||||
imdb_id = imdbid_elem.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Check for legacy <imdb>ttXXXXXX</imdb>
|
||||
imdb_elem = root.find('.//imdb')
|
||||
if imdb_elem is not None and imdb_elem.text:
|
||||
imdb_id = imdb_elem.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Last resort: Check for TMDB ID as fallback identifier
|
||||
# This handles movies that only have TMDB IDs in NFO files
|
||||
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
|
||||
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
|
||||
tmdb_id = tmdb_uniqueid.text.strip()
|
||||
if tmdb_id.isdigit():
|
||||
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
|
||||
# Return TMDB ID with prefix to distinguish from IMDb IDs
|
||||
return f"tmdb-{tmdb_id}"
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
# Skip corrupted or non-XML files
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||
"""Find IMDb ID from directory name, filenames, or NFO file"""
|
||||
# First try directory name
|
||||
imdb_id = self.parse_imdb_from_path(movie_dir)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory for IMDb ID patterns
|
||||
for file_path in movie_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = self.parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Finally, try NFO file content (including TMDB fallback)
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
|
||||
def find_series_imdb_id(self, series_dir: Path) -> Optional[str]:
|
||||
"""Find IMDb ID from TV series directory name, filenames, or tvshow.nfo file"""
|
||||
# First try directory name
|
||||
imdb_id = self.parse_imdb_from_path(series_dir)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory for IMDb ID patterns
|
||||
for file_path in series_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = self.parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Finally, try tvshow.nfo file content
|
||||
nfo_path = series_dir / "tvshow.nfo"
|
||||
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
|
||||
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract NFOGuard-managed dates from existing NFO file"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for NFOGuard fields
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
premiered_elem = root.find('.//premiered')
|
||||
aired_elem = root.find('.//aired') # For TV episodes
|
||||
lockdata_elem = root.find('.//lockdata')
|
||||
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# Extract original source from NFOGuard comment, default to nfo_file_existing
|
||||
source = "nfo_file_existing"
|
||||
|
||||
# Parse XML content to find NFOGuard comment with source
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
print(f"🔍 Extracted original source from NFO comment: {source}")
|
||||
|
||||
result = {
|
||||
"source": source
|
||||
}
|
||||
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
result["dateadded"] = dateadded_elem.text.strip()
|
||||
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
result["released"] = premiered_elem.text.strip()
|
||||
|
||||
if aired_elem is not None and aired_elem.text:
|
||||
result["aired"] = aired_elem.text.strip()
|
||||
|
||||
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
|
||||
"""Extract NFOGuard-managed dates from existing episode NFO file"""
|
||||
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
|
||||
nfo_path = season_path / nfo_filename
|
||||
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for NFOGuard fields in episode NFO
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
aired_elem = root.find('.//aired')
|
||||
lockdata_elem = root.find('.//lockdata')
|
||||
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete episode NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# Extract original source from NFOGuard comment, default to episode_nfo_existing
|
||||
source = "episode_nfo_existing"
|
||||
|
||||
# Parse XML content to find NFOGuard comment with source
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
print(f"🔍 Extracted original source from episode NFO comment: {source}")
|
||||
|
||||
result = {
|
||||
"source": source
|
||||
}
|
||||
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
result["dateadded"] = dateadded_elem.text.strip()
|
||||
|
||||
if aired_elem is not None and aired_elem.text:
|
||||
result["aired"] = aired_elem.text.strip()
|
||||
|
||||
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result.get('dateadded', 'None')}, source={source}, aired={result.get('aired', 'None')}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _parse_nfo_with_tolerance(self, nfo_path: Path):
|
||||
"""Parse NFO file with tolerance for URLs appended after XML"""
|
||||
try:
|
||||
# First try normal parsing
|
||||
tree = ET.parse(nfo_path)
|
||||
return tree.getroot()
|
||||
except ET.ParseError as e:
|
||||
# If parsing fails, try to extract just the XML part
|
||||
try:
|
||||
with open(nfo_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find the last </movie> tag and truncate after it
|
||||
last_movie_end = content.rfind('</movie>')
|
||||
if last_movie_end != -1:
|
||||
xml_content = content[:last_movie_end + 8] # +8 for </movie>
|
||||
|
||||
# Try parsing the truncated content
|
||||
root = ET.fromstring(xml_content)
|
||||
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
|
||||
return root
|
||||
else:
|
||||
# Re-raise original error if we can't find </movie>
|
||||
raise e
|
||||
except Exception:
|
||||
# Re-raise original error if our fix attempt fails
|
||||
raise e
|
||||
|
||||
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
||||
released: Optional[str] = None, source: str = "unknown",
|
||||
lock_metadata: bool = True) -> None:
|
||||
"""Create or update movie.nfo file preserving existing content"""
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
|
||||
# Debug output only if DEBUG=true in environment
|
||||
import os
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
|
||||
print(f"🔍 NFO path: {nfo_path}")
|
||||
print(f"🔍 NFO exists: {nfo_path.exists()}")
|
||||
|
||||
try:
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
# Try to parse the XML, handling URLs appended after </movie>
|
||||
movie = self._parse_nfo_with_tolerance(nfo_path)
|
||||
|
||||
# Ensure root element is <movie>
|
||||
if movie.tag != "movie":
|
||||
raise ValueError("Root element is not <movie>")
|
||||
|
||||
# Only remove elements that are clearly NFOGuard-managed
|
||||
# Look for elements that have NFOGuard characteristics
|
||||
elements_to_remove = []
|
||||
|
||||
# Remove lockdata=true (this is definitely NFOGuard)
|
||||
for lockdata in movie.findall("lockdata"):
|
||||
if lockdata.text == "true":
|
||||
elements_to_remove.append(lockdata)
|
||||
|
||||
# Remove IMDb uniqueids that we manage
|
||||
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
||||
elements_to_remove.append(uniqueid)
|
||||
|
||||
# For dateadded/premiered/year, only remove if they appear to be NFOGuard-managed
|
||||
# (i.e., if lockdata=true exists, these are likely ours)
|
||||
has_nfoguard_lockdata = any(ld.text == "true" for ld in movie.findall("lockdata"))
|
||||
|
||||
if has_nfoguard_lockdata:
|
||||
# This NFO was managed by NFOGuard, safe to remove our fields
|
||||
for tag in ["dateadded", "premiered", "year"]:
|
||||
existing = movie.find(tag)
|
||||
if existing is not None:
|
||||
# Store the value before removing (for premiered/year)
|
||||
if tag == "premiered" and not released:
|
||||
print(f"🔍 Preserving existing premiered date: {existing.text}")
|
||||
released = existing.text
|
||||
elements_to_remove.append(existing)
|
||||
else:
|
||||
# No NFOGuard lockdata found, be more conservative
|
||||
# Only remove dateadded if it looks like NFOGuard format (ISO timestamp)
|
||||
dateadded_elem = movie.find("dateadded")
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
# NFOGuard uses ISO format like "2025-10-12 16:26:02"
|
||||
if re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', dateadded_elem.text.strip()):
|
||||
elements_to_remove.append(dateadded_elem)
|
||||
|
||||
# Remove all identified elements
|
||||
for elem in elements_to_remove:
|
||||
movie.remove(elem)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean NFO file to replace corrupted one")
|
||||
movie = ET.Element("movie")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
movie = ET.Element("movie")
|
||||
|
||||
# Create all NFOGuard elements first, then append them in correct order
|
||||
# This ensures they appear as a group at the bottom of the file
|
||||
nfoguard_elements = []
|
||||
|
||||
# Add IMDb uniqueid
|
||||
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
|
||||
uniqueid.text = imdb_id
|
||||
nfoguard_elements.append(uniqueid)
|
||||
|
||||
# Add premiered date if we have it
|
||||
if released:
|
||||
premiered_elem = ET.Element("premiered")
|
||||
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
||||
nfoguard_elements.append(premiered_elem)
|
||||
|
||||
# Extract year from premiered date for consistency
|
||||
try:
|
||||
year_value = released[:4] if len(released) >= 4 else None
|
||||
if year_value and year_value.isdigit():
|
||||
year_elem = ET.Element("year")
|
||||
year_elem.text = year_value
|
||||
nfoguard_elements.append(year_elem)
|
||||
except:
|
||||
pass # Skip year if we can't extract it
|
||||
|
||||
# Add dateadded - THIS IS CRITICAL FOR EMBY PLUGIN
|
||||
if os.environ.get("DEBUG", "false").lower() == "true":
|
||||
print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})")
|
||||
if dateadded:
|
||||
dateadded_elem = ET.Element("dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
nfoguard_elements.append(dateadded_elem)
|
||||
print(f"✅ Adding dateadded to NFO: {dateadded}")
|
||||
else:
|
||||
print(f"❌ dateadded is empty/None, not adding to NFO")
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.Element("lockdata")
|
||||
lockdata.text = "true"
|
||||
nfoguard_elements.append(lockdata)
|
||||
|
||||
# Add NFOGuard comment as the very last element (appears at bottom)
|
||||
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
|
||||
nfoguard_elements.append(nfoguard_comment)
|
||||
|
||||
# Now append all NFOGuard elements to the movie in one batch
|
||||
# This ensures they appear as a contiguous block at the bottom
|
||||
for elem in nfoguard_elements:
|
||||
movie.append(elem)
|
||||
|
||||
print(f"✅ Added {len(nfoguard_elements)} NFOGuard elements to bottom of NFO")
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(movie)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write directly to file (comment is already embedded in XML at bottom)
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
|
||||
tree.write(f, encoding='unicode', xml_declaration=False)
|
||||
|
||||
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
|
||||
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
|
||||
|
||||
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
||||
"""Create or update tvshow.nfo file preserving existing content"""
|
||||
nfo_path = series_dir / "tvshow.nfo"
|
||||
|
||||
try:
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
tvshow = tree.getroot()
|
||||
|
||||
# Ensure root element is <tvshow>
|
||||
if tvshow.tag != "tvshow":
|
||||
raise ValueError("Root element is not <tvshow>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# These will be re-added at the bottom
|
||||
for tag in ["lockdata"]:
|
||||
existing = tvshow.find(tag)
|
||||
if existing is not None:
|
||||
tvshow.remove(existing)
|
||||
|
||||
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
||||
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
|
||||
tvshow.remove(uniqueid)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
|
||||
tvshow = ET.Element("tvshow")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
tvshow = ET.Element("tvshow")
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Add IMDb uniqueid at the end
|
||||
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
|
||||
imdb_uniqueid.text = imdb_id
|
||||
|
||||
# Add TVDB ID if available (preserve existing or add new)
|
||||
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
|
||||
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
|
||||
tvdb_uniqueid.text = tvdb_id
|
||||
|
||||
# Add lockdata at the very end
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} ")
|
||||
tvshow.append(comment)
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(tvshow)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
|
||||
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
|
||||
|
||||
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
||||
"""Create or update season.nfo file preserving existing content"""
|
||||
nfo_path = season_dir / "season.nfo"
|
||||
|
||||
try:
|
||||
season_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
season = tree.getroot()
|
||||
|
||||
# Ensure root element is <season>
|
||||
if season.tag != "season":
|
||||
raise ValueError("Root element is not <season>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements
|
||||
for tag in ["seasonnumber", "lockdata"]:
|
||||
existing = season.find(tag)
|
||||
if existing is not None:
|
||||
season.remove(existing)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean season.nfo file to replace corrupted one")
|
||||
season = ET.Element("season")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
season = ET.Element("season")
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
seasonnumber = ET.SubElement(season, "seasonnumber")
|
||||
seasonnumber.text = str(season_number)
|
||||
|
||||
# Add lockdata at the end
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} ")
|
||||
season.append(comment)
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(season)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||
print(f" Season: {season_number}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
|
||||
|
||||
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||
"""Find any existing episode NFO file that matches season/episode"""
|
||||
if not season_dir.exists():
|
||||
return None
|
||||
|
||||
# Look for NFO files in the season directory
|
||||
for nfo_file in season_dir.glob("*.nfo"):
|
||||
|
||||
# Check if this NFO contains the right season/episode
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag == "episodedetails":
|
||||
# Check for season/episode elements
|
||||
season_elem = root.find("season")
|
||||
episode_elem = root.find("episode")
|
||||
|
||||
if (season_elem is not None and episode_elem is not None and
|
||||
season_elem.text and episode_elem.text):
|
||||
try:
|
||||
file_season = int(season_elem.text)
|
||||
file_episode = int(episode_elem.text)
|
||||
|
||||
if file_season == season_num and file_episode == episode_num:
|
||||
print(f"🔍 Found existing episode NFO: {nfo_file.name}")
|
||||
return nfo_file
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
# Skip corrupted or non-XML files
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
|
||||
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
|
||||
if not season_dir.exists():
|
||||
return f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
|
||||
# First check if an NFO already exists for this episode
|
||||
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||
if existing_nfo:
|
||||
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
|
||||
return existing_nfo.name
|
||||
|
||||
# Look for video files with matching season/episode
|
||||
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
|
||||
|
||||
for video_file in season_dir.iterdir():
|
||||
if (video_file.is_file() and
|
||||
video_file.suffix.lower() in video_extensions):
|
||||
|
||||
# Parse episode info from video filename
|
||||
episode_info = self._parse_episode_from_filename(video_file.name)
|
||||
if episode_info and episode_info == (season_num, episode_num):
|
||||
# Found matching video file - use its name for NFO
|
||||
target_nfo_name = f"{video_file.stem}.nfo"
|
||||
print(f"🎯 Target NFO will match video: {target_nfo_name}")
|
||||
return target_nfo_name
|
||||
|
||||
# Fallback to short name if no matching video found
|
||||
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
print(f"⚠️ No matching video file found, using short name: {short_name}")
|
||||
return short_name
|
||||
|
||||
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
|
||||
"""Parse season and episode numbers from filename"""
|
||||
# Try S##E## format first
|
||||
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
# Try ##x## format
|
||||
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
return None
|
||||
|
||||
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Create or update episode NFO file preserving existing content"""
|
||||
# Get target NFO filename (prefer long name matching video file)
|
||||
target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
|
||||
nfo_path = season_dir / target_nfo_name
|
||||
|
||||
try:
|
||||
# Check for existing NFO file at target location
|
||||
source_nfo_path = nfo_path if nfo_path.exists() else None
|
||||
|
||||
if source_nfo_path:
|
||||
try:
|
||||
tree = ET.parse(source_nfo_path)
|
||||
episode = tree.getroot()
|
||||
|
||||
# Ensure root element is <episodedetails>
|
||||
if episode.tag != "episodedetails":
|
||||
raise ValueError("Root element is not <episodedetails>")
|
||||
|
||||
print(f"📝 Updating existing NFO: {nfo_path.name}")
|
||||
|
||||
# Preserve existing content fields and store NFOGuard-managed fields for re-adding
|
||||
preserved_values = {}
|
||||
|
||||
# Extract and preserve NFOGuard-managed fields (we'll re-add these at the bottom)
|
||||
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
|
||||
for tag in nfoguard_fields:
|
||||
existing = episode.find(tag)
|
||||
if existing is not None:
|
||||
# Store the value before removing
|
||||
if tag == "aired" and not aired:
|
||||
aired = existing.text # Preserve existing aired date
|
||||
preserved_values[tag] = existing.text
|
||||
episode.remove(existing)
|
||||
|
||||
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
|
||||
# These should be preserved from the long-named NFO files
|
||||
|
||||
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
|
||||
if self.debug:
|
||||
preserved_fields = [elem.tag for elem in episode]
|
||||
if preserved_fields:
|
||||
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
|
||||
else:
|
||||
print(f" ℹ️ NFO contains only NFOGuard metadata (no additional content fields)")
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean episode NFO file to replace corrupted one")
|
||||
episode = ET.Element("episodedetails")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
episode = ET.Element("episodedetails")
|
||||
|
||||
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
|
||||
if enhanced_metadata:
|
||||
if enhanced_metadata.get("title") and not episode.find("title"):
|
||||
title_elem = ET.SubElement(episode, "title")
|
||||
title_elem.text = enhanced_metadata["title"]
|
||||
|
||||
if enhanced_metadata.get("overview") and not episode.find("plot"):
|
||||
plot_elem = ET.SubElement(episode, "plot")
|
||||
plot_elem.text = enhanced_metadata["overview"]
|
||||
|
||||
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
|
||||
runtime_elem = ET.SubElement(episode, "runtime")
|
||||
runtime_elem.text = str(enhanced_metadata["runtime"])
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Basic episode info at the end
|
||||
season_elem = ET.SubElement(episode, "season")
|
||||
season_elem.text = str(season_num)
|
||||
|
||||
episode_elem = ET.SubElement(episode, "episode")
|
||||
episode_elem.text = str(episode_num)
|
||||
|
||||
# Dates at the end
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode, "aired")
|
||||
# Convert datetime objects to strings
|
||||
aired_str = str(aired)
|
||||
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
|
||||
|
||||
# Debug logging for dateadded
|
||||
print(f"🔍 DEBUG: dateadded value: {repr(dateadded)} (type: {type(dateadded)})")
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||
# Convert datetime objects to strings
|
||||
dateadded_elem.text = str(dateadded)
|
||||
print(f"✅ Added dateadded to episode NFO: {dateadded}")
|
||||
else:
|
||||
print(f"❌ dateadded is empty/None, not adding to episode NFO")
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(episode, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode.append(comment)
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(episode)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||
|
||||
# NFO file created/updated successfully
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||
|
||||
def set_file_mtime(self, file_path: Path, iso_timestamp) -> None:
|
||||
"""Set file modification time to match import date"""
|
||||
try:
|
||||
# Convert datetime objects to strings first
|
||||
if hasattr(iso_timestamp, 'isoformat'):
|
||||
iso_timestamp = iso_timestamp.isoformat()
|
||||
elif not isinstance(iso_timestamp, str):
|
||||
iso_timestamp = str(iso_timestamp)
|
||||
|
||||
# Parse ISO timestamp
|
||||
if iso_timestamp.endswith('Z'):
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
||||
elif '+' in iso_timestamp or 'T' in iso_timestamp:
|
||||
dt = datetime.fromisoformat(iso_timestamp)
|
||||
elif ' ' in iso_timestamp:
|
||||
# Handle space-separated datetime format (e.g., "2025-10-16 20:31:22")
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace(' ', 'T'))
|
||||
else:
|
||||
# Assume it's a simple date (e.g., "2025-10-16")
|
||||
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
|
||||
|
||||
# Convert to timestamp
|
||||
timestamp = dt.timestamp()
|
||||
|
||||
# Set both access and modification times
|
||||
os.utime(file_path, (timestamp, timestamp))
|
||||
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error setting mtime for {file_path}: {e}")
|
||||
|
||||
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
||||
"""Update modification times for all video files in movie directory"""
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||
updated_files = []
|
||||
|
||||
for file_path in movie_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
||||
self.set_file_mtime(file_path, iso_timestamp)
|
||||
updated_files.append(file_path.name)
|
||||
|
||||
if updated_files:
|
||||
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
|
||||
else:
|
||||
print(f"⚠️ No video files found to update in {movie_dir.name}")
|
||||
@@ -22,7 +22,7 @@ from utils.logging import _log
|
||||
|
||||
# Import core components
|
||||
from core.database import NFOGuardDatabase
|
||||
# from core.nfo_manager import NFOManager # Phase 3: Removed - no longer needed
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
|
||||
# Import clients
|
||||
@@ -93,20 +93,20 @@ def initialize_components():
|
||||
|
||||
# Initialize core components
|
||||
db = NFOGuardDatabase(config=config)
|
||||
# nfo_manager = NFOManager(config.manager_brand, config.debug) # Phase 3: Removed
|
||||
nfo_manager = NFOManager(config.manager_brand, config.debug)
|
||||
path_mapper = PathMapper(config)
|
||||
|
||||
# Initialize processors (nfo_manager=None for backward compatibility)
|
||||
tv_processor = TVProcessor(db, None, path_mapper)
|
||||
movie_processor = MovieProcessor(db, None, path_mapper)
|
||||
# Initialize processors
|
||||
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
|
||||
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
|
||||
|
||||
# Initialize webhook batcher (no longer needs nfo_manager - Phase 3)
|
||||
batcher = WebhookBatcher(nfo_manager=None)
|
||||
# Initialize webhook batcher with nfo_manager for comprehensive IMDb detection
|
||||
batcher = WebhookBatcher(nfo_manager)
|
||||
batcher.set_processors(tv_processor, movie_processor)
|
||||
|
||||
return {
|
||||
"db": db,
|
||||
# "nfo_manager": nfo_manager, # Phase 3: Removed
|
||||
"nfo_manager": nfo_manager,
|
||||
"path_mapper": path_mapper,
|
||||
"tv_processor": tv_processor,
|
||||
"movie_processor": movie_processor,
|
||||
|
||||
@@ -5,7 +5,7 @@ Provides endpoints for the web-based database manipulation interface
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import HTTPException, Query, BackgroundTasks
|
||||
from fastapi import HTTPException, Query
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
@@ -14,10 +14,6 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from api.models import *
|
||||
|
||||
|
||||
# Global status tracking for database population
|
||||
_populate_status = {"running": False, "completed": False}
|
||||
|
||||
|
||||
def map_source_to_description(source: str) -> str:
|
||||
"""Map technical source codes to user-friendly descriptions"""
|
||||
if not source or source == "no_valid_date_source":
|
||||
@@ -52,23 +48,15 @@ def map_source_to_description(source: str) -> str:
|
||||
elif "omdb:" in source_lower:
|
||||
return "OMDb Release"
|
||||
|
||||
# Sonarr sources
|
||||
elif "sonarr:" in source_lower:
|
||||
return "Sonarr API"
|
||||
|
||||
# Manual and other sources
|
||||
elif "manual" in source_lower:
|
||||
return "Manual Entry"
|
||||
elif "digital_release" in source_lower:
|
||||
return "Digital Release"
|
||||
elif "nfo_file_existing" in source_lower:
|
||||
return "NFO File (Legacy)"
|
||||
elif "nfo:" in source_lower:
|
||||
return "NFO File"
|
||||
elif "webhook:" in source_lower:
|
||||
return "Webhook/API"
|
||||
elif "database" in source_lower:
|
||||
return "Database"
|
||||
|
||||
# Fallback for unknown patterns
|
||||
return source.title()
|
||||
@@ -481,17 +469,10 @@ async def get_dashboard_stats(dependencies: dict):
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
|
||||
episodes_no_valid_source = db._get_first_value(cursor.fetchone())
|
||||
|
||||
# Recent activity (last 7 days) - count items processed, not history events
|
||||
# Recent activity (last 7 days)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT imdb_id FROM movies
|
||||
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||
OR updated_at > NOW() - INTERVAL '7 days'
|
||||
UNION
|
||||
SELECT DISTINCT imdb_id FROM episodes
|
||||
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||
OR updated_at > NOW() - INTERVAL '7 days'
|
||||
) AS recent_items
|
||||
SELECT COUNT(*) FROM processing_history
|
||||
WHERE processed_at > NOW() - INTERVAL '7 days'
|
||||
""")
|
||||
recent_activity = db._get_first_value(cursor.fetchone())
|
||||
|
||||
@@ -1028,111 +1009,6 @@ async def delete_movie(dependencies: dict, imdb_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}")
|
||||
|
||||
|
||||
async def populate_database(background_tasks: BackgroundTasks, media_type: str = "both", dependencies: dict = None):
|
||||
"""
|
||||
Populate NFOGuard database from Radarr/Sonarr sources
|
||||
|
||||
Args:
|
||||
background_tasks: FastAPI background tasks
|
||||
media_type: Type of media to populate ("movies", "tv", or "both")
|
||||
dependencies: Dictionary with db, radarr_client, sonarr_client
|
||||
|
||||
Returns:
|
||||
Status message indicating population has started
|
||||
"""
|
||||
from core.database_populator import DatabasePopulator
|
||||
|
||||
db = dependencies["db"]
|
||||
config = dependencies["config"]
|
||||
|
||||
# Get Radarr and Sonarr clients
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.sonarr_client import SonarrClient
|
||||
|
||||
radarr_client = RadarrClient(config)
|
||||
sonarr_client = SonarrClient(config)
|
||||
|
||||
if media_type not in ["both", "movies", "tv"]:
|
||||
raise HTTPException(status_code=400, detail="media_type must be 'both', 'movies', or 'tv'")
|
||||
|
||||
# Create global status tracking
|
||||
populate_status = {
|
||||
"running": True,
|
||||
"media_type": media_type,
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"movies": {"status": "pending", "stats": None},
|
||||
"tv": {"status": "pending", "stats": None},
|
||||
"completed": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# Store status globally so it can be queried
|
||||
global _populate_status
|
||||
_populate_status = populate_status
|
||||
|
||||
async def run_population():
|
||||
"""Background task to populate the database"""
|
||||
try:
|
||||
populator = DatabasePopulator(db, radarr_client, sonarr_client)
|
||||
|
||||
print(f"INFO: Starting database population: {media_type}")
|
||||
|
||||
if media_type == "movies":
|
||||
populate_status["movies"]["status"] = "running"
|
||||
movie_stats = populator.populate_movies()
|
||||
populate_status["movies"]["status"] = "completed"
|
||||
populate_status["movies"]["stats"] = movie_stats
|
||||
print(f"INFO: Movie population completed: {movie_stats}")
|
||||
|
||||
elif media_type == "tv":
|
||||
populate_status["tv"]["status"] = "running"
|
||||
tv_stats = populator.populate_tv_episodes()
|
||||
populate_status["tv"]["status"] = "completed"
|
||||
populate_status["tv"]["stats"] = tv_stats
|
||||
print(f"INFO: TV population completed: {tv_stats}")
|
||||
|
||||
elif media_type == "both":
|
||||
populate_status["movies"]["status"] = "running"
|
||||
movie_stats = populator.populate_movies()
|
||||
populate_status["movies"]["status"] = "completed"
|
||||
populate_status["movies"]["stats"] = movie_stats
|
||||
print(f"INFO: Movie population completed: {movie_stats}")
|
||||
|
||||
populate_status["tv"]["status"] = "running"
|
||||
tv_stats = populator.populate_tv_episodes()
|
||||
populate_status["tv"]["status"] = "completed"
|
||||
populate_status["tv"]["stats"] = tv_stats
|
||||
print(f"INFO: TV population completed: {tv_stats}")
|
||||
|
||||
populate_status["completed"] = True
|
||||
populate_status["running"] = False
|
||||
print("INFO: Database population completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Database population failed: {e}")
|
||||
populate_status["error"] = str(e)
|
||||
populate_status["running"] = False
|
||||
populate_status["completed"] = True
|
||||
|
||||
# Add task to background
|
||||
background_tasks.add_task(run_population)
|
||||
|
||||
print(f"INFO: Database population started for: {media_type}")
|
||||
return {
|
||||
"status": "started",
|
||||
"media_type": media_type,
|
||||
"message": f"Database population started for {media_type}"
|
||||
}
|
||||
|
||||
|
||||
async def get_populate_status():
|
||||
"""Get the current status of database population"""
|
||||
global _populate_status
|
||||
if '_populate_status' not in globals():
|
||||
return {"running": False, "completed": False}
|
||||
return _populate_status
|
||||
|
||||
|
||||
def register_web_routes(app, dependencies):
|
||||
"""Register all web API routes with FastAPI app"""
|
||||
from fastapi import Request, Response
|
||||
@@ -1257,28 +1133,6 @@ def register_web_routes(app, dependencies):
|
||||
response.delete_cookie("nfoguard_session")
|
||||
return {"status": "logged_out", "message": "Session cleared"}
|
||||
|
||||
# Database population endpoints
|
||||
print("🔧 DEBUG: Registering /admin/populate-database endpoint...")
|
||||
|
||||
@app.post("/admin/populate-database")
|
||||
async def api_populate_database(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Populate database from Radarr/Sonarr"""
|
||||
print(f"🔥 DEBUG: populate-database endpoint called!")
|
||||
try:
|
||||
data = await request.json()
|
||||
media_type = data.get("media_type", "both")
|
||||
except Exception:
|
||||
# Fallback to query parameter if JSON parsing fails
|
||||
media_type = request.query_params.get("media_type", "both")
|
||||
return await populate_database(background_tasks, media_type, dependencies)
|
||||
|
||||
print("✅ DEBUG: /admin/populate-database registered")
|
||||
|
||||
@app.get("/api/populate/status")
|
||||
async def api_populate_status():
|
||||
"""Get database population status"""
|
||||
return await get_populate_status()
|
||||
|
||||
# Health endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
@@ -1286,7 +1140,3 @@ def register_web_routes(app, dependencies):
|
||||
return {"status": "healthy", "service": "nfoguard-web"}
|
||||
|
||||
print("✅ Web routes registered successfully")
|
||||
print(f"📋 DEBUG: Registered routes count: {len(app.routes)}")
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'path') and hasattr(route, 'methods'):
|
||||
print(f" - {route.methods} {route.path}")
|
||||
@@ -33,7 +33,7 @@ def create_web_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.9.0-fixes-only-files",
|
||||
version="2.6.12-web",
|
||||
docs_url="/docs" if web_config.web_debug else None,
|
||||
redoc_url="/redoc" if web_config.web_debug else None
|
||||
)
|
||||
|
||||
@@ -49,10 +49,32 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Header Logo and Text Layout */
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-logo .logo {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
/* Clean display for new logo */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header-content h1 i {
|
||||
@@ -62,6 +84,28 @@ body {
|
||||
.header-content p {
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive logo layout */
|
||||
@media (max-width: 768px) {
|
||||
.header-logo {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-logo .logo {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Authentication Status */
|
||||
@@ -844,59 +888,454 @@ body {
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
|
||||
/* Manual Scan Styles */
|
||||
.scan-status {
|
||||
/* Database Admin Tools Styles */
|
||||
.query-results {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--light-color);
|
||||
background: #f8f9fa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-progress {
|
||||
.query-info {
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
.query-info.success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.query-info.error {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
background-color: #e9ecef;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
background-color: var(--dark-color);
|
||||
color: white;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.results-table tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.results-table td em {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.query-results .table-container,
|
||||
.admin-results .table-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* NFO Repair Table Styling */
|
||||
.nfo-repair-table-container {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nfo-repair-table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.nfo-repair-table th {
|
||||
background-color: var(--dark-color);
|
||||
color: white;
|
||||
padding: 0.75rem 0.5rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nfo-repair-table td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.nfo-repair-table tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.media-type-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-type-badge.episode {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.media-type-badge.movie {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.status-ready {
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status-missing {
|
||||
color: #d32f2f;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.nfo-fix-actions {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nfo-fix-actions .btn {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
.admin-tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scan-info span:first-child {
|
||||
.admin-tools .btn {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-results {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-summary,
|
||||
.fix-summary {
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scan-summary {
|
||||
background-color: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
}
|
||||
|
||||
.fix-summary.success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.fix-errors {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fix-errors ul {
|
||||
margin: 0.5rem 0 0 1rem;
|
||||
}
|
||||
|
||||
.missing-episodes {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.scan-info span:last-child {
|
||||
.loading::before {
|
||||
content: "⏳ ";
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Make text areas more distinctive */
|
||||
textarea {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Button variations for admin tools */
|
||||
.btn.btn-info {
|
||||
background-color: #17a2b8;
|
||||
border-color: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.btn-info:hover {
|
||||
background-color: #138496;
|
||||
border-color: #117a8b;
|
||||
}
|
||||
|
||||
/* Missing IMDb Items Styles */
|
||||
.missing-imdb-table-container {
|
||||
overflow-x: auto;
|
||||
max-height: 600px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.missing-imdb-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.missing-imdb-table thead {
|
||||
background-color: var(--bg-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.missing-imdb-table th,
|
||||
.missing-imdb-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.missing-imdb-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.missing-imdb-table td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.missing-imdb-table .file-path {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-type-badge.tv-series {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.media-type-badge.movie {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.missing-imdb-actions {
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.missing-imdb-actions ul {
|
||||
margin: 0.5rem 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.missing-imdb-actions li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.missing-imdb-actions code {
|
||||
background-color: var(--bg-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* NFO Statistics Styling */
|
||||
.nfo-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
.recommendation-box {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.recommendation-box ul {
|
||||
margin: 0.5rem 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.recommendation-box li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for admin tools */
|
||||
@media (max-width: 768px) {
|
||||
.table-container {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.results-table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-tools {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.missing-imdb-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.missing-imdb-table th,
|
||||
.missing-imdb-table td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.missing-imdb-table .file-path {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
+141
-332
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NFOGuard - Database Management</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=2.10.0-skipped-imdb-edit-v2">
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=2.8.2-20241026-nfo-table-redesign">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
@@ -12,9 +12,13 @@
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1><i class="fas fa-shield-alt"></i> NFOGuard <span style="font-size: 0.5em; color: #888;">v2.10.0-skipped-imdb-edit-v2</span></h1>
|
||||
<div class="header-logo">
|
||||
<div class="header-text">
|
||||
<h1>NFOGuard</h1>
|
||||
<p>Database Management & Reporting</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-status" id="auth-status" style="display: none;">
|
||||
<span class="auth-user">
|
||||
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
|
||||
@@ -36,9 +40,6 @@
|
||||
<button class="nav-tab" data-tab="reports">
|
||||
<i class="fas fa-chart-bar"></i> Reports
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="scheduled-scans">
|
||||
<i class="fas fa-clock"></i> Scheduled Scans
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="tools">
|
||||
<i class="fas fa-tools"></i> Tools
|
||||
</button>
|
||||
@@ -49,6 +50,24 @@
|
||||
<main class="main-content">
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="dashboard">
|
||||
<!-- Scan Status Banner -->
|
||||
<div class="scan-status-banner" id="dashboard-scan-status" style="display: none;">
|
||||
<div class="scan-status-content">
|
||||
<div class="scan-status-icon">
|
||||
<i class="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
<div class="scan-status-info">
|
||||
<h4>Scan in Progress</h4>
|
||||
<p id="dashboard-scan-text">Processing media files...</p>
|
||||
<div class="scan-progress-mini">
|
||||
<div class="progress-bar-mini">
|
||||
<div class="progress-fill-mini" id="dashboard-scan-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon movies">
|
||||
@@ -93,17 +112,6 @@
|
||||
<small>Last 7 days</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);">
|
||||
<i class="fas fa-ban"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="skipped-total">-</h3>
|
||||
<p>Skipped Items</p>
|
||||
<small id="skipped-breakdown">- movies, - episodes</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-charts">
|
||||
@@ -138,7 +146,6 @@
|
||||
<option value="">All Movies</option>
|
||||
<option value="true">With Dates</option>
|
||||
<option value="false">Missing Dates</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
</select>
|
||||
<select id="movies-filter-source">
|
||||
<option value="">All Sources</option>
|
||||
@@ -151,30 +158,16 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table sortable-table" id="movies-table">
|
||||
<table class="data-table" id="movies-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 0, 'text')" style="cursor: pointer;">
|
||||
Title <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 1, 'text')" style="cursor: pointer;">
|
||||
IMDb ID <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 2, 'date')" style="cursor: pointer;">
|
||||
Movie Released <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 3, 'date')" style="cursor: pointer;">
|
||||
Date Added to Library <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 4, 'text')" style="cursor: pointer;">
|
||||
Source <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 5, 'text')" style="cursor: pointer;">
|
||||
Date Type <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('movies-tbody', 6, 'text')" style="cursor: pointer;">
|
||||
Video File <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th>Title</th>
|
||||
<th>IMDb ID</th>
|
||||
<th>Movie Released</th>
|
||||
<th>Date Added to Library</th>
|
||||
<th>Source</th>
|
||||
<th>Date Type</th>
|
||||
<th>Video File</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -210,7 +203,6 @@
|
||||
<option value="complete">Fully Dated</option>
|
||||
<option value="incomplete">Missing Dates</option>
|
||||
<option value="none">No Dates</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
</select>
|
||||
<select id="series-filter-source">
|
||||
<option value="">All Sources</option>
|
||||
@@ -223,27 +215,14 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table sortable-table" id="series-table">
|
||||
<table class="data-table" id="series-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 0, 'text')" style="cursor: pointer;">
|
||||
Series Title <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 1, 'text')" style="cursor: pointer;">
|
||||
IMDb ID <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 2, 'number')" style="cursor: pointer;">
|
||||
Episodes <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 3, 'number')" style="cursor: pointer;">
|
||||
With Dates <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 4, 'number')" style="cursor: pointer;">
|
||||
With Video <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" onclick="sortTable('series-tbody', 5, 'number')" style="cursor: pointer;">
|
||||
Skipped <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th>Series Title</th>
|
||||
<th>IMDb ID</th>
|
||||
<th>Episodes</th>
|
||||
<th>With Dates</th>
|
||||
<th>With Video</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -330,114 +309,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Scans Tab -->
|
||||
<div class="tab-content" id="scheduled-scans">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-clock"></i> Scheduled Scans</h2>
|
||||
<button class="btn btn-primary" id="add-schedule-btn">
|
||||
<i class="fas fa-plus"></i> Add Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Schedules Section -->
|
||||
<div class="section-card">
|
||||
<h3><i class="fas fa-list"></i> Active Schedules</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Mode</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last Run</th>
|
||||
<th>Next Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedules-table-body">
|
||||
<!-- Schedules will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execution History Section -->
|
||||
<div class="section-card">
|
||||
<h3><i class="fas fa-history"></i> Recent Executions</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="executions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schedule</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Items Processed</th>
|
||||
<th>Items Skipped</th>
|
||||
<th>Items Failed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="executions-table-body">
|
||||
<!-- Executions will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools Tab -->
|
||||
<div class="tab-content" id="tools">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-tools"></i> Database Tools</h2>
|
||||
</div>
|
||||
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
|
||||
<p>Change source for multiple items at once</p>
|
||||
<form id="bulk-update-form">
|
||||
<div class="form-group">
|
||||
<label>Media Type:</label>
|
||||
<select id="bulk-media-type" required>
|
||||
<option value="">Select type...</option>
|
||||
<option value="movies">Movies</option>
|
||||
<option value="episodes">Episodes</option>
|
||||
</select>
|
||||
<!-- Scan Status Display -->
|
||||
<div class="scan-status-card" id="scan-status" style="display: none;">
|
||||
<div class="scan-status-header">
|
||||
<h3><i class="fas fa-sync fa-spin"></i> Scan in Progress</h3>
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>From Source:</label>
|
||||
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
|
||||
<span class="progress-text" id="scan-progress-text">Initializing...</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>To Source:</label>
|
||||
<select id="bulk-new-source" required>
|
||||
<option value="">Select new source...</option>
|
||||
<option value="airdate">Air Date</option>
|
||||
<option value="digital_release">Digital Release</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="radarr:db.history.import">Radarr Import</option>
|
||||
<option value="sonarr:history.import">Sonarr Import</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="fas fa-exchange-alt"></i> Update Sources
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tools-grid">
|
||||
<!-- Manual Scan Tools -->
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
||||
<p>Scan specific folders or perform full library scans</p>
|
||||
<p>Run manual scans on your media library</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="both">Both (Movies & TV)</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Series Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -445,33 +347,38 @@
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
<option value="incomplete">Incomplete Items Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Specific Path (Optional):</label>
|
||||
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
|
||||
<small>Leave empty to scan entire library</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
<div id="scan-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="scan-current-operation">Initializing...</span>
|
||||
<span id="scan-progress-text">0%</span>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-folder"></i> Custom Directory Scan</h3>
|
||||
<p>Scan a specific directory or path</p>
|
||||
<form id="custom-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Directory Path:</label>
|
||||
<input type="text" id="scan-path" placeholder="/media/Movies/specific-folder" />
|
||||
<small>Enter the full path to scan (will be auto-formatted)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="custom-scan-type" required>
|
||||
<option value="both">Auto-detect</option>
|
||||
<option value="movies">Movies</option>
|
||||
<option value="tv">TV Series</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-search"></i> Scan Directory
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||
@@ -484,37 +391,83 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Database Query Tool -->
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-upload"></i> Populate Database</h3>
|
||||
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
|
||||
<form id="populate-form">
|
||||
<h3><i class="fas fa-terminal"></i> Database Query Tool</h3>
|
||||
<p>Execute custom SQL queries on the NFOGuard database</p>
|
||||
<form id="database-query-form">
|
||||
<div class="form-group">
|
||||
<label>Media Type:</label>
|
||||
<select id="populate-media-type" required>
|
||||
<option value="both">Movies & TV Shows</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<label>Quick Queries:</label>
|
||||
<select id="quick-query-select" onchange="loadQuickQuery()">
|
||||
<option value="">Select a predefined query...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Population
|
||||
<div class="form-group">
|
||||
<label>SQL Query:</label>
|
||||
<textarea id="sql-query" rows="4" placeholder="SELECT * FROM episodes WHERE dateadded IS NULL LIMIT 10"></textarea>
|
||||
<small>Query will be automatically limited to 100 results for safety</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Result Limit:</label>
|
||||
<select id="query-limit">
|
||||
<option value="10">10 rows</option>
|
||||
<option value="50">50 rows</option>
|
||||
<option value="100" selected>100 rows</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-info">
|
||||
<i class="fas fa-play"></i> Execute Query
|
||||
</button>
|
||||
</form>
|
||||
<div id="populate-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="populate-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="populate-current-operation">Running...</span>
|
||||
<span id="populate-progress-text">In Progress</span>
|
||||
<div id="query-results" class="query-results" style="display: none;">
|
||||
<h4>Query Results:</h4>
|
||||
<div id="query-results-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
|
||||
<!-- NFO File Repair Tool -->
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-file-medical"></i> NFO File Repair</h3>
|
||||
<p>Fix missing <dateadded> elements in NFO files</p>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-warning" onclick="checkMissingNFODates()">
|
||||
<i class="fas fa-search"></i> Scan for Missing dateadded in NFO Files
|
||||
</button>
|
||||
</div>
|
||||
<div id="nfo-scan-results" style="display: none;">
|
||||
<div id="nfo-scan-content"></div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-success" onclick="fixAllMissingNFODates()">
|
||||
<i class="fas fa-wrench"></i> Fix All Missing dateadded Elements
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="fixSpecificSeries()">
|
||||
<i class="fas fa-cog"></i> Fix Specific Series
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Admin Tools -->
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-user-shield"></i> Database Admin</h3>
|
||||
<p>Advanced database management tools</p>
|
||||
<div class="admin-tools">
|
||||
<button class="btn btn-info" onclick="showEpisodesMissingNFODateadded()">
|
||||
<i class="fas fa-exclamation-triangle"></i> Episodes Missing NFO dateadded
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="showMissingImdbItems()">
|
||||
<i class="fas fa-search"></i> Items Missing IMDb IDs
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="exportDatabaseReport()">
|
||||
<i class="fas fa-download"></i> Export Database Report
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="showRecentWebhookActivity()">
|
||||
<i class="fas fa-clock"></i> Recent Webhook Activity
|
||||
</button>
|
||||
</div>
|
||||
<div id="admin-results" class="admin-results" style="display: none;">
|
||||
<div id="admin-results-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -580,153 +533,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<div class="modal" id="schedule-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="schedule-modal-title">Add New Schedule</h3>
|
||||
<button class="modal-close" onclick="closeScheduleModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="schedule-form">
|
||||
<input type="hidden" id="schedule-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-name">Schedule Name:</label>
|
||||
<input type="text" id="schedule-name" required placeholder="e.g., Daily TV Incomplete Scan">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-description">Description:</label>
|
||||
<textarea id="schedule-description" placeholder="Optional description of what this schedule does"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="schedule-media-type">Media Type:</label>
|
||||
<select id="schedule-media-type" required>
|
||||
<option value="both">Both TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-scan-mode">Scan Mode:</label>
|
||||
<select id="schedule-scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
<option value="full">Full Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-cron">Schedule (Cron Expression):</label>
|
||||
<div class="cron-input-container">
|
||||
<input type="text" id="schedule-cron" required placeholder="0 2 * * *" pattern="^(\*|[0-5]?\d|\*\/[0-9]+)(\s+(\*|[0-5]?\d|\*\/[0-9]+)){4}$">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="cron-builder-btn">
|
||||
<i class="fas fa-magic"></i> Builder
|
||||
</button>
|
||||
</div>
|
||||
<small class="help-text">
|
||||
Examples: "0 2 * * *" (daily at 2 AM), "0 2 * * 0" (weekly on Sunday at 2 AM)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-paths">Specific Paths (Optional):</label>
|
||||
<textarea id="schedule-paths" placeholder="Leave empty to scan entire library, or specify paths separated by commas"></textarea>
|
||||
<small class="help-text">
|
||||
Example: /media/TV/Series Name, /media/Movies/Movie Name
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="schedule-enabled" checked>
|
||||
<span class="checkmark"></span>
|
||||
Enable this schedule
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeScheduleModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span id="schedule-submit-text">Create Schedule</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Builder Modal -->
|
||||
<div class="modal" id="cron-builder-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Cron Expression Builder</h3>
|
||||
<button class="modal-close" onclick="closeCronBuilder()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="cron-builder">
|
||||
<div class="cron-presets">
|
||||
<h4>Quick Presets:</h4>
|
||||
<div class="preset-buttons">
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 * * *')">Daily at 2 AM</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 * * 0')">Weekly (Sunday 2 AM)</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 1 * *')">Monthly (1st at 2 AM)</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 */6 * * *')">Every 6 Hours</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 */12 * * *')">Every 12 Hours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-fields">
|
||||
<h4>Custom Schedule:</h4>
|
||||
<div class="field-group">
|
||||
<label>Minute (0-59):</label>
|
||||
<input type="text" id="cron-minute" value="0" placeholder="0">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Hour (0-23):</label>
|
||||
<input type="text" id="cron-hour" value="2" placeholder="2">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Day of Month (1-31):</label>
|
||||
<input type="text" id="cron-day" value="*" placeholder="*">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Month (1-12):</label>
|
||||
<input type="text" id="cron-month" value="*" placeholder="*">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Day of Week (0-6):</label>
|
||||
<input type="text" id="cron-dow" value="*" placeholder="*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-preview">
|
||||
<h4>Preview:</h4>
|
||||
<div class="cron-expression">
|
||||
<code id="cron-preview-text">0 2 * * *</code>
|
||||
</div>
|
||||
<div class="cron-description" id="cron-description">
|
||||
Runs daily at 2:00 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCronBuilder()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="applyCronExpression()">Use This Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js?v=2.10.0-skipped-imdb-edit-v2"></script>
|
||||
<script src="/static/js/app.js?v=3.0.1-nfo-table-redesign"></script>
|
||||
</body>
|
||||
</html>
|
||||
+818
-1049
File diff suppressed because it is too large
Load Diff
+214
-78
@@ -11,12 +11,12 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
from clients.radarr_client import RadarrClient
|
||||
from clients.external_clients import ExternalClientManager
|
||||
from config.settings import config
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import find_imdb_in_directory # Phase 3: Replaced NFOManager
|
||||
from utils.file_utils import find_media_path_by_imdb_and_title
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ def convert_utc_to_local(utc_iso_string: str) -> str:
|
||||
class MovieProcessor:
|
||||
"""Handles movie processing"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
|
||||
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager
|
||||
self.path_mapper = path_mapper
|
||||
self.radarr = RadarrClient(
|
||||
os.environ.get("RADARR_URL", ""),
|
||||
@@ -151,9 +151,9 @@ class MovieProcessor:
|
||||
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
|
||||
return False, f"Error checking completion: {e}"
|
||||
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, scan_mode: str = "smart", shutdown_event=None) -> str:
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, shutdown_event=None) -> str:
|
||||
"""Process a movie directory"""
|
||||
imdb_id = find_imdb_in_directory(movie_path) # Phase 3: Using imdb_utils instead of NFOManager
|
||||
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
|
||||
return "error"
|
||||
@@ -165,9 +165,8 @@ class MovieProcessor:
|
||||
else:
|
||||
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
|
||||
|
||||
# Check if we should skip this movie (unless forced, webhook mode, or incomplete mode)
|
||||
# Skip database optimization for incomplete mode since we need to check NFO files first
|
||||
if not force_scan and not webhook_mode and scan_mode != "incomplete":
|
||||
# Check if we should skip this movie (unless forced or webhook mode)
|
||||
if not force_scan and not webhook_mode:
|
||||
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
|
||||
if should_skip:
|
||||
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||
@@ -197,11 +196,6 @@ class MovieProcessor:
|
||||
_log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
|
||||
return "no_video_files"
|
||||
|
||||
# For incomplete mode: Start with NFO check to find missing dateadded elements
|
||||
if scan_mode == "incomplete":
|
||||
return self._process_movie_nfo_first(movie_path, imdb_id, shutdown_event)
|
||||
|
||||
# For smart/full modes: Use database-first optimization
|
||||
# TIER 1: Check database first (fastest - local lookup)
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
||||
@@ -225,16 +219,106 @@ class MovieProcessor:
|
||||
if released and hasattr(released, 'isoformat'):
|
||||
released = released.isoformat()
|
||||
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Create NFO with existing data and update files
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
|
||||
return "processed"
|
||||
else:
|
||||
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
|
||||
|
||||
# TIER 2: Query external APIs directly (NFO layer removed in Phase 2)
|
||||
_log("INFO", f"🔍 TIER 2 - No database cache, querying external APIs")
|
||||
# TIER 2: Check if NFO file has NFOGuard data and cache it in database
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
|
||||
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
|
||||
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
|
||||
|
||||
if nfo_data:
|
||||
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
|
||||
dateadded = nfo_data["dateadded"]
|
||||
source = nfo_data["source"]
|
||||
released = nfo_data.get("released")
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
|
||||
|
||||
# Update file mtimes if enabled (NFO is already correct)
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-cached]")
|
||||
return "processed"
|
||||
|
||||
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
|
||||
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
|
||||
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
|
||||
if existing_nfo_data and config.manual_scan_prioritize_nfo:
|
||||
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
|
||||
dateadded = existing_nfo_data["dateadded"]
|
||||
source = existing_nfo_data["source"]
|
||||
released = existing_nfo_data.get("released")
|
||||
|
||||
# Cache existing data in database and add proper NFOGuard formatting
|
||||
# Fixed parameter order: imdb_id, released, dateadded, source
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
|
||||
|
||||
# Update NFO file to add NFOGuard formatting (lockdata, comment)
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
|
||||
|
||||
# Update file mtimes if enabled
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [existing-nfo-enhanced]")
|
||||
return "processed"
|
||||
elif existing_nfo_data:
|
||||
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
|
||||
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
|
||||
# Store NFO data as fallback but continue to TIER 3 to check external APIs
|
||||
nfo_fallback_data = existing_nfo_data
|
||||
|
||||
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
|
||||
if is_tmdb_fallback:
|
||||
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
|
||||
if tmdb_nfo_data:
|
||||
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
|
||||
dateadded = tmdb_nfo_data["dateadded"]
|
||||
source = tmdb_nfo_data["source"]
|
||||
released = tmdb_nfo_data.get("released")
|
||||
|
||||
# Create NFO with NFOGuard fields added
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes if enabled
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo]")
|
||||
return "processed"
|
||||
|
||||
# TIER 3: No cached data found - proceed with API lookups and verification
|
||||
|
||||
# Check for shutdown signal before expensive API operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
@@ -272,8 +356,18 @@ class MovieProcessor:
|
||||
final_source = f"{source}_as_dateadded" if source else "release_date_fallback"
|
||||
_log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})")
|
||||
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Create NFO regardless of date availability (preserves existing metadata)
|
||||
if config.debug:
|
||||
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
|
||||
if config.manage_nfo:
|
||||
if config.debug:
|
||||
print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}")
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata
|
||||
)
|
||||
else:
|
||||
if config.debug:
|
||||
print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation")
|
||||
|
||||
# Skip remaining processing if no valid date found and file dates disabled
|
||||
if final_dateadded is None:
|
||||
@@ -287,8 +381,9 @@ class MovieProcessor:
|
||||
|
||||
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
|
||||
|
||||
# File mtime operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Update file mtimes (only if we have a valid date)
|
||||
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
|
||||
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
|
||||
|
||||
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
|
||||
|
||||
@@ -305,75 +400,85 @@ class MovieProcessor:
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
|
||||
def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
|
||||
"""Process movie for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE: Checking movie for missing data: {movie_path.name}")
|
||||
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract date information from TMDB-based NFO file"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
# Check for shutdown signal
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
|
||||
return "shutdown"
|
||||
try:
|
||||
root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path)
|
||||
|
||||
# STEP 1: Check database for existing data (Phase 2: NFO check removed)
|
||||
_log("DEBUG", f"STEP 1 - Checking database for existing data")
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
# Look for premiered date (from TMDB)
|
||||
premiered_elem = root.find('.//premiered')
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
premiered_date = premiered_elem.text.strip()
|
||||
print(f"✅ Found TMDB premiered date: {premiered_date}")
|
||||
|
||||
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||
# Found in database - data is complete
|
||||
_log("INFO", f"✅ Database has dateadded={existing['dateadded']}")
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
return {
|
||||
"dateadded": premiered_date,
|
||||
"source": "tmdb:premiered_from_nfo",
|
||||
"released": premiered_date
|
||||
}
|
||||
|
||||
# Convert datetime objects to strings
|
||||
if hasattr(dateadded, 'isoformat'):
|
||||
dateadded = dateadded.isoformat()
|
||||
if released and hasattr(released, 'isoformat'):
|
||||
released = released.isoformat()
|
||||
except (ET.ParseError, Exception) as e:
|
||||
print(f"⚠️ Error parsing TMDB NFO for dates: {e}")
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
|
||||
return "processed"
|
||||
return None
|
||||
|
||||
# STEP 2: Database incomplete or missing, query APIs
|
||||
_log("DEBUG", f"STEP 2 - Querying APIs for missing data")
|
||||
def _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract any valid date information from NFO file, even without NFOGuard markers"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
# Check for shutdown signal before API calls
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping before API calls: {movie_path.name}")
|
||||
return "shutdown"
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Handle TMDB ID fallback case
|
||||
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||
# Look for dateadded element (indicates previously processed by NFOGuard or similar)
|
||||
dateadded_elem = root.find('.//dateadded')
|
||||
premiered_elem = root.find('.//premiered')
|
||||
|
||||
if is_tmdb_fallback:
|
||||
# TMDB fallback processing - use file modification time
|
||||
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime for TMDB movie: {dateadded}")
|
||||
if dateadded_elem is not None and dateadded_elem.text:
|
||||
dateadded = dateadded_elem.text.strip()
|
||||
|
||||
# Try to determine source from NFOGuard comment if present
|
||||
source = "existing_nfo_data"
|
||||
try:
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
import re
|
||||
# Look for NFOGuard comment pattern
|
||||
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
|
||||
if source_match:
|
||||
source = source_match.group(1).strip()
|
||||
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
|
||||
else:
|
||||
# Standard IMDb processing
|
||||
# Try to get digital release date from external APIs
|
||||
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
||||
|
||||
if digital_date:
|
||||
dateadded = digital_date
|
||||
source = digital_source
|
||||
released = digital_date # For movies, digital release is often the key date
|
||||
_log("INFO", f"Got digital release date from APIs: {dateadded} (source: {source})")
|
||||
# Try to infer source from dateadded format/content
|
||||
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text):
|
||||
source = "tmdb:digital"
|
||||
elif "radarr" in nfo_content.lower():
|
||||
source = "radarr:db.history.import"
|
||||
else:
|
||||
# Last resort: file modification time
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime as fallback: {dateadded}")
|
||||
source = "existing_nfo_data"
|
||||
_log("DEBUG", f"Inferred source from NFO content: {source}")
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Could not determine source from NFO content: {e}")
|
||||
|
||||
# Save to database only (NFO operations removed in Phase 1)
|
||||
if dateadded:
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
result = {
|
||||
"dateadded": dateadded,
|
||||
"source": source
|
||||
}
|
||||
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
else:
|
||||
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
|
||||
return "error"
|
||||
if premiered_elem is not None and premiered_elem.text:
|
||||
result["released"] = premiered_elem.text.strip()
|
||||
|
||||
# NFO helper methods removed in Phase 2 - database is the single source of truth
|
||||
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}")
|
||||
return result
|
||||
|
||||
except (ET.ParseError, Exception) as e:
|
||||
_log("DEBUG", f"Error parsing NFO for existing date data: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
|
||||
"""Decide movie dates based on configuration and available data"""
|
||||
@@ -441,7 +546,14 @@ class MovieProcessor:
|
||||
# When using digital release date, store it as both dateadded and released
|
||||
return digital_date, digital_source, digital_date
|
||||
else:
|
||||
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found")
|
||||
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
|
||||
|
||||
# Try Radarr's own NFO premiered date as fallback
|
||||
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
||||
if radarr_premiered:
|
||||
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
|
||||
# When using Radarr NFO premiered date, store it as both dateadded and released
|
||||
return radarr_premiered, "radarr:nfo.premiered", radarr_premiered
|
||||
|
||||
else: # digital_then_import
|
||||
# Try digital release first
|
||||
@@ -500,7 +612,31 @@ class MovieProcessor:
|
||||
_log("ERROR", f"External clients error for {imdb_id}: {e}")
|
||||
return None, f"release:error:{str(e)}"
|
||||
|
||||
# _get_radarr_nfo_premiered_date() removed in Phase 2 - no longer reading NFO files
|
||||
def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]:
|
||||
"""Extract premiered date from Radarr's existing movie.nfo file"""
|
||||
try:
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
if not nfo_path.exists():
|
||||
_log("DEBUG", f"No existing NFO file found at {nfo_path}")
|
||||
return None
|
||||
|
||||
nfo_content = nfo_path.read_text(encoding='utf-8')
|
||||
|
||||
# Look for <premiered>YYYY-MM-DD</premiered>
|
||||
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', nfo_content)
|
||||
if match:
|
||||
premiered_date = match.group(1)
|
||||
# Convert to ISO format with timezone
|
||||
iso_date = f"{premiered_date}T00:00:00+00:00"
|
||||
_log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}")
|
||||
return iso_date
|
||||
else:
|
||||
_log("DEBUG", f"No <premiered> tag found in existing NFO")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error reading Radarr NFO file: {e}")
|
||||
return None
|
||||
|
||||
def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None):
|
||||
"""Log movies that failed to get valid dates to a debug file"""
|
||||
|
||||
+164
-133
@@ -11,12 +11,13 @@ from typing import Optional, Dict, List, Set, Tuple, Any
|
||||
from datetime import datetime
|
||||
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.nfo_manager import NFOManager
|
||||
from core.async_nfo_manager import AsyncNFOManager
|
||||
from core.path_mapper import PathMapper
|
||||
from clients.sonarr_client import SonarrClient
|
||||
from clients.external_clients import ExternalClientManager
|
||||
from config.settings import config
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import parse_imdb_from_path # Phase 3: Replaced NFOManager
|
||||
from utils.file_utils import (
|
||||
find_media_path_by_imdb_and_title,
|
||||
find_episodes_on_disk,
|
||||
@@ -31,9 +32,10 @@ from utils.async_file_utils import (
|
||||
class TVProcessor:
|
||||
"""Handles TV series processing"""
|
||||
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager, path_mapper: PathMapper):
|
||||
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
|
||||
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
|
||||
self.db = db
|
||||
self.nfo_manager = nfo_manager
|
||||
self.async_nfo_manager = AsyncNFOManager(config.manager_brand, config.debug)
|
||||
self.path_mapper = path_mapper
|
||||
self.sonarr = SonarrClient(
|
||||
os.environ.get("SONARR_URL", ""),
|
||||
@@ -143,9 +145,9 @@ class TVProcessor:
|
||||
_log("ERROR", f"Error checking series completion for {imdb_id}: {e}")
|
||||
return False, f"Error checking completion: {e}"
|
||||
|
||||
def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart") -> str:
|
||||
def process_series(self, series_path: Path, force_scan: bool = False) -> str:
|
||||
"""Process a TV series directory"""
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return "error"
|
||||
@@ -153,8 +155,7 @@ class TVProcessor:
|
||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||
|
||||
# Fast check first - avoid expensive filesystem scan if possible
|
||||
# Skip fast optimization for incomplete mode since we need to check NFO files first
|
||||
if not force_scan and scan_mode != "incomplete":
|
||||
if not force_scan:
|
||||
should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name)
|
||||
if should_skip_fast:
|
||||
_log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}")
|
||||
@@ -191,8 +192,19 @@ class TVProcessor:
|
||||
if (season, episode) in disk_episodes:
|
||||
episode_count += 1
|
||||
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = disk_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
@@ -214,17 +226,12 @@ class TVProcessor:
|
||||
return extract_title_from_directory_name(series_path.name)
|
||||
|
||||
|
||||
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]], scan_mode: str = "smart") -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode air dates and date added information with optimization based on scan mode"""
|
||||
_log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk (mode: {scan_mode})")
|
||||
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode air dates and date added information with database-first optimization"""
|
||||
_log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk")
|
||||
episode_dates = {}
|
||||
episodes_needing_lookup = []
|
||||
|
||||
# For incomplete mode: Start with NFO check to find missing dateadded elements
|
||||
if scan_mode == "incomplete":
|
||||
return self._gather_episode_dates_nfo_first(series_path, imdb_id, disk_episodes)
|
||||
|
||||
# For smart/full modes: Use database-first optimization
|
||||
# TIER 1: Check database first for existing dates (fastest)
|
||||
_log("DEBUG", f"TIER 1 - Checking database for existing episode dates for {len(disk_episodes)} episodes")
|
||||
db_cache_hits = 0
|
||||
@@ -246,12 +253,64 @@ class TVProcessor:
|
||||
# Not in database or incomplete - needs NFO check
|
||||
episodes_needing_nfo_check.append((season, episode))
|
||||
|
||||
_log("INFO", f"TIER 1 - Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_nfo_check)}")
|
||||
_log("INFO", f"Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need NFO check: {len(episodes_needing_nfo_check)}")
|
||||
|
||||
# TIER 2: Query Sonarr API for episodes not in database (NFO layer removed in Phase 2)
|
||||
episodes_needing_lookup = episodes_needing_nfo_check # Renamed for clarity
|
||||
# TIER 2: Check NFO files for NFOGuard dates and cache them in database
|
||||
nfo_cache_hits = 0
|
||||
episodes_needing_lookup = []
|
||||
|
||||
if episodes_needing_nfo_check:
|
||||
_log("DEBUG", f"TIER 2 - Checking NFO files for NFOGuard dates for {len(episodes_needing_nfo_check)} episodes")
|
||||
|
||||
for (season, episode) in episodes_needing_nfo_check:
|
||||
# Look for existing NFO files for this episode
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
episode_files = disk_episodes[(season, episode)]
|
||||
|
||||
nfo_found = False
|
||||
for episode_file in episode_files:
|
||||
# Try to find matching NFO file
|
||||
nfo_path = episode_file.with_suffix('.nfo')
|
||||
if nfo_path.exists():
|
||||
# Extract NFOGuard data from episode NFO
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
if nfo_data:
|
||||
aired = nfo_data.get('aired')
|
||||
dateadded = nfo_data.get('dateadded')
|
||||
source = nfo_data.get('source', 'nfo_cache')
|
||||
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO data found - aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
# Skip incomplete NFO files with "unknown" source or no useful dates
|
||||
if source == "unknown" and not dateadded and not aired:
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Ignoring incomplete NFO file with source 'unknown' and no dates")
|
||||
break # Break out of NFO search to mark episode as needing API lookup
|
||||
|
||||
# Apply fallback logic if NFO has aired but no dateadded
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
source = f"{source}_aired_fallback" if source != 'nfo_cache' else 'nfo_aired_fallback'
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has aired but no dateadded, using aired as fallback: {dateadded}")
|
||||
|
||||
if dateadded:
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
nfo_cache_hits += 1
|
||||
nfo_found = True
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
_log("DEBUG", f"NFO cache hit for S{season:02d}E{episode:02d}: {dateadded} - cached in DB")
|
||||
break
|
||||
|
||||
if not nfo_found:
|
||||
# No NFO data found - needs API lookup
|
||||
episodes_needing_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"NFO cache hits: {nfo_cache_hits}/{len(episodes_needing_nfo_check)} episodes. Need API lookup: {len(episodes_needing_lookup)}")
|
||||
|
||||
# TIER 3: Only call Sonarr API for episodes not in database or NFO files
|
||||
if episodes_needing_lookup:
|
||||
_log("DEBUG", f"TIER 2 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database")
|
||||
_log("DEBUG", f"TIER 3 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database and NFO files")
|
||||
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup)
|
||||
|
||||
# Process episodes that needed lookup
|
||||
@@ -265,12 +324,6 @@ class TVProcessor:
|
||||
sonarr_data = sonarr_episodes[(season, episode)]
|
||||
aired = sonarr_data.get('airDate')
|
||||
dateadded = sonarr_data.get('dateAdded')
|
||||
|
||||
# Enhanced debugging for Season 0 (Specials)
|
||||
if season == 0:
|
||||
_log("DEBUG", f"SPECIALS DEBUG S{season:02d}E{episode:02d}: Full Sonarr data: {sonarr_data}")
|
||||
_log("DEBUG", f"SPECIALS DEBUG S{season:02d}E{episode:02d}: airDate='{aired}', dateAdded='{dateadded}'")
|
||||
|
||||
if dateadded:
|
||||
source = "sonarr:history.import"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||
@@ -319,89 +372,6 @@ class TVProcessor:
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode dates for incomplete mode: Database-first then API (NFO checks removed in Phase 2)"""
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE: Checking {len(disk_episodes)} episodes for missing data")
|
||||
episode_dates = {}
|
||||
episodes_needing_api_lookup = []
|
||||
|
||||
# STEP 1: Check database for all episodes (NFO check removed in Phase 2)
|
||||
_log("DEBUG", f"STEP 1 - Checking database for {len(disk_episodes)} episodes")
|
||||
db_hits = 0
|
||||
|
||||
for (season, episode) in disk_episodes:
|
||||
db_result = self.db.get_episode_date(imdb_id, season, episode)
|
||||
|
||||
if db_result and db_result.get('dateadded'):
|
||||
# Found in database - use cached data
|
||||
aired = db_result.get('aired')
|
||||
dateadded = db_result.get('dateadded')
|
||||
source = db_result.get('source', 'database_cache')
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
db_hits += 1
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded}")
|
||||
else:
|
||||
# Not in database or incomplete - needs API lookup
|
||||
episodes_needing_api_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database hits: {db_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
|
||||
|
||||
# STEP 2: For episodes missing from database, query Sonarr
|
||||
if episodes_needing_api_lookup:
|
||||
_log("DEBUG", f"STEP 2 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from database")
|
||||
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup)
|
||||
|
||||
for (season, episode) in episodes_needing_api_lookup:
|
||||
aired = None
|
||||
dateadded = None
|
||||
source = "unknown"
|
||||
|
||||
# Try Sonarr first
|
||||
if (season, episode) in sonarr_episodes:
|
||||
sonarr_data = sonarr_episodes[(season, episode)]
|
||||
aired = sonarr_data.get('airDate')
|
||||
dateadded = sonarr_data.get('dateAdded')
|
||||
|
||||
if dateadded:
|
||||
source = "sonarr:history.import"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||
else:
|
||||
source = "sonarr:no_import_date"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Sonarr has data but no dateAdded (aired: {aired})")
|
||||
|
||||
# Fallback to external sources if needed
|
||||
if not aired:
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: No aired date from Sonarr, trying external APIs")
|
||||
external_aired = self.external_clients.get_episode_air_date(imdb_id, season, episode)
|
||||
if external_aired:
|
||||
aired = external_aired
|
||||
if not dateadded:
|
||||
source = "external"
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Found aired date from external APIs: {aired}")
|
||||
|
||||
# Use air date as fallback for dateadded if no import date found
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
if source == "sonarr:no_import_date":
|
||||
source = "sonarr:aired_fallback"
|
||||
elif source == "external":
|
||||
source = "external:aired_fallback"
|
||||
else:
|
||||
source = f"{source}_fallback" if source != "unknown" else "aired_fallback"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Using aired date as dateadded fallback: {dateadded}")
|
||||
|
||||
# Save to database for future lookups
|
||||
if dateadded or aired:
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
|
||||
_log("INFO", f"🔍 INCOMPLETE MODE COMPLETE: {len(episode_dates)} episodes processed")
|
||||
for (s, e), (aired, dateadded, source) in episode_dates.items():
|
||||
_log("INFO", f" S{s:02d}E{e:02d}: aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, int]] = None) -> Dict[Tuple[int, int], Dict[str, Any]]:
|
||||
"""Get episode information from Sonarr including import history - optimized to only fetch needed episodes"""
|
||||
try:
|
||||
@@ -469,11 +439,6 @@ class TVProcessor:
|
||||
'dateAdded': None
|
||||
}
|
||||
|
||||
# Enhanced debugging for Season 0 (Specials)
|
||||
if season == 0:
|
||||
_log("DEBUG", f"SPECIALS SONARR RAW S{season:02d}E{episode_num:02d}: airDate='{episode.get('airDate')}', hasFile={episode.get('hasFile')}")
|
||||
_log("DEBUG", f"SPECIALS SONARR RAW S{season:02d}E{episode_num:02d}: Full episode data: {episode}")
|
||||
|
||||
# First try to get import date from history (more accurate)
|
||||
episode_id = episode.get('id')
|
||||
if episode_id and episode.get('hasFile'):
|
||||
@@ -541,8 +506,18 @@ class TVProcessor:
|
||||
processed_count = 0
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in season_episodes:
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_path,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = season_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
@@ -611,8 +586,16 @@ class TVProcessor:
|
||||
|
||||
aired, dateadded, source = episode_dates[(season_num, episode_num)]
|
||||
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_path,
|
||||
season_num, episode_num, aired, dateadded, source, config.lock_metadata
|
||||
)
|
||||
|
||||
# Update file mtime
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
self.nfo_manager.set_file_mtime(episode_path, dateadded)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||
@@ -640,7 +623,7 @@ class TVProcessor:
|
||||
Returns:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
return {"status": "error", "reason": f"No IMDb ID found in series path: {series_path}"}
|
||||
|
||||
@@ -662,8 +645,25 @@ class TVProcessor:
|
||||
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in disk_episodes:
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
|
||||
# Prepare NFO creation data
|
||||
if config.manage_nfo:
|
||||
episode_data_list.append({
|
||||
'season_dir': season_dir,
|
||||
'season': season,
|
||||
'episode': episode,
|
||||
'aired': aired,
|
||||
'dateadded': dateadded,
|
||||
'source': source,
|
||||
'lock_metadata': config.lock_metadata
|
||||
})
|
||||
|
||||
# Prepare mtime operations
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
video_files = disk_episodes[(season, episode)]
|
||||
for video_file in video_files:
|
||||
mtime_operations.append((video_file, dateadded))
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
@@ -673,10 +673,27 @@ class TVProcessor:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
|
||||
# NFO and mtime operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Process NFOs and mtimes concurrently
|
||||
results = {}
|
||||
|
||||
if episode_data_list:
|
||||
_log("INFO", f"Creating {len(episode_data_list)} episode NFOs concurrently")
|
||||
nfo_results = await self.async_nfo_manager.async_batch_create_episode_nfos(
|
||||
episode_data_list,
|
||||
max_concurrent=config.max_concurrent
|
||||
)
|
||||
results['nfo_created'] = sum(nfo_results)
|
||||
results['nfo_failed'] = len(nfo_results) - sum(nfo_results)
|
||||
|
||||
if mtime_operations:
|
||||
_log("INFO", f"Setting mtimes for {len(mtime_operations)} files concurrently")
|
||||
mtime_results = await self.async_nfo_manager.async_batch_set_file_mtimes(
|
||||
mtime_operations,
|
||||
max_concurrent=10
|
||||
)
|
||||
results['mtime_updated'] = sum(mtime_results)
|
||||
results['mtime_failed'] = len(mtime_results) - sum(mtime_results)
|
||||
|
||||
_log("INFO", f"Completed async processing TV series: {series_path.name}")
|
||||
|
||||
return {
|
||||
@@ -733,7 +750,7 @@ class TVProcessor:
|
||||
|
||||
def process_webhook_episodes(self, series_path: Path, webhook_episodes: List[Dict[str, Any]]) -> None:
|
||||
"""Process only the specific episodes mentioned in a webhook (targeted mode)"""
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return
|
||||
@@ -761,7 +778,7 @@ class TVProcessor:
|
||||
continue
|
||||
|
||||
# Check if episode file exists on disk
|
||||
season_dir = series_path / config.get_season_dir_name(season_num)
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
if not season_dir.exists():
|
||||
_log("WARNING", f"Season directory not found: {season_dir}")
|
||||
continue
|
||||
@@ -783,8 +800,18 @@ class TVProcessor:
|
||||
aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode)
|
||||
enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir)
|
||||
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season_num, episode_num, aired, dateadded, source, config.lock_metadata,
|
||||
enhanced_metadata
|
||||
)
|
||||
|
||||
# Update file mtimes
|
||||
if config.fix_dir_mtimes and dateadded:
|
||||
for episode_file in episode_files:
|
||||
self.nfo_manager.set_file_mtime(episode_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
|
||||
@@ -804,7 +831,7 @@ class TVProcessor:
|
||||
# for webhook_episode in webhook_episodes:
|
||||
# season_num = webhook_episode.get("seasonNumber")
|
||||
# if season_num and season_num not in seasons_processed:
|
||||
# season_dir = series_path / config.get_season_dir_name(season_num)
|
||||
# season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
# if season_dir.exists():
|
||||
# self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
# seasons_processed.add(season_num)
|
||||
@@ -1039,13 +1066,17 @@ class TVProcessor:
|
||||
dateadded = episode_data.get('dateadded')
|
||||
|
||||
# Get IMDb ID
|
||||
imdb_id = parse_imdb_from_path(series_path) # Phase 3: Using imdb_utils
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
return {"status": "error", "reason": "No IMDb ID found"}
|
||||
|
||||
# NFO file operations removed - database is now the single source of truth
|
||||
# (Phase 1: Remove NFO file write operations)
|
||||
nfo_success = True # Always True since we're not creating NFOs anymore
|
||||
# Create NFO if needed
|
||||
nfo_success = True
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
|
||||
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
|
||||
)
|
||||
|
||||
# Update database
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, "webhook", True)
|
||||
|
||||
@@ -29,19 +29,6 @@ class TVSeriesProcessor:
|
||||
self.sonarr = sonarr_client
|
||||
self.external_manager = ExternalClientManager()
|
||||
|
||||
# Initialize Sonarr database client for high-performance queries
|
||||
self.sonarr_db = None
|
||||
try:
|
||||
from clients.sonarr_db_client import SonarrDbClient
|
||||
self.sonarr_db = SonarrDbClient.from_env()
|
||||
if self.sonarr_db:
|
||||
_log("INFO", "✅ SONARR DB MODE: Direct database access enabled")
|
||||
else:
|
||||
_log("INFO", "⚠️ SONARR API MODE: Using API (configure SONARR_DB_TYPE for better performance)")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr DB client initialization failed, using API: {e}")
|
||||
self.sonarr_db = None
|
||||
|
||||
def process_series_manual_scan(self, series_path: Path) -> bool:
|
||||
"""Process a TV series during manual scan"""
|
||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||
@@ -232,62 +219,11 @@ class TVSeriesProcessor:
|
||||
return False
|
||||
|
||||
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""Get episode dates from Sonarr (DB first, then API fallback)"""
|
||||
"""Get episode dates from Sonarr API with proper fallback"""
|
||||
aired = None
|
||||
dateadded = None
|
||||
source = "no_data_found"
|
||||
|
||||
# TIER 1: Try Sonarr database (high performance)
|
||||
if self.sonarr_db:
|
||||
try:
|
||||
# Find series by IMDb
|
||||
series = self.sonarr_db.get_series_by_imdb(imdb_id)
|
||||
if series:
|
||||
series_id = series['id']
|
||||
|
||||
# Get all episodes for series
|
||||
episodes = self.sonarr_db.get_all_episodes_for_series(series_id)
|
||||
|
||||
# Find target episode
|
||||
target_episode = None
|
||||
for ep in episodes:
|
||||
if ep['season'] == season_num and ep['episode'] == episode_num:
|
||||
target_episode = ep
|
||||
break
|
||||
|
||||
if target_episode:
|
||||
# Get air date
|
||||
aired = target_episode.get('air_date')
|
||||
|
||||
# Try to get import date from history
|
||||
episode_id = target_episode['id']
|
||||
import_date, import_source = self.sonarr_db.get_episode_import_date(episode_id)
|
||||
|
||||
if import_date:
|
||||
dateadded = import_date
|
||||
source = import_source
|
||||
_log("INFO", f"✅ DB: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Fallback to episode file date
|
||||
file_date = self.sonarr_db.get_episode_file_date(series_id, season_num, episode_num)
|
||||
if file_date:
|
||||
dateadded = file_date
|
||||
source = "sonarr:db.file.dateAdded"
|
||||
_log("INFO", f"✅ DB: Using file date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Use air date as fallback
|
||||
if aired:
|
||||
dateadded = convert_utc_to_local(aired)
|
||||
source = "sonarr:db.airDate"
|
||||
_log("WARNING", f"⚠️ DB: No import date, using air date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr DB query failed for S{season_num:02d}E{episode_num:02d}, falling back to API: {e}")
|
||||
|
||||
# TIER 2: Fall back to Sonarr API
|
||||
if not self.sonarr.enabled:
|
||||
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
|
||||
return aired, dateadded, source
|
||||
@@ -330,15 +266,15 @@ class TVSeriesProcessor:
|
||||
# Sonarr import dates are already in local timezone despite 'Z' suffix
|
||||
# Remove 'Z' and use as-is to avoid double timezone conversion
|
||||
dateadded = import_date.replace('Z', '') if 'Z' in import_date else import_date
|
||||
source = "sonarr:api.history.import"
|
||||
_log("INFO", f"✅ API: Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
source = "sonarr:history.import"
|
||||
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded} (no timezone conversion)")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Fallback to airdate if no import history
|
||||
if aired:
|
||||
dateadded = convert_utc_to_local(aired)
|
||||
source = "sonarr:api.episode.airDateUtc"
|
||||
_log("WARNING", f"⚠️ API: No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||
source = "sonarr:episode.airDateUtc"
|
||||
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
|
||||
return aired, dateadded, source
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -8,5 +8,3 @@ aiofiles==23.2.1
|
||||
aiohttp==3.8.6
|
||||
psutil==5.9.6
|
||||
python-dotenv==1.0.0
|
||||
APScheduler==3.10.4
|
||||
croniter==1.4.1
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Scheduler module for NFOGuard
|
||||
@@ -1,396 +0,0 @@
|
||||
"""
|
||||
NFOGuard Background Scheduler
|
||||
Manages scheduled scans using APScheduler with cron-like functionality
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.jobstores.memory import MemoryJobStore
|
||||
from apscheduler.executors.asyncio import AsyncIOExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NFOGuardScheduler:
|
||||
"""
|
||||
Background scheduler for NFOGuard that manages scheduled scans
|
||||
"""
|
||||
|
||||
def __init__(self, dependencies: Dict[str, Any]):
|
||||
"""Initialize the scheduler with dependencies"""
|
||||
self.dependencies = dependencies
|
||||
self.scheduler = None
|
||||
self.running = False
|
||||
|
||||
# Configure APScheduler
|
||||
jobstores = {
|
||||
'default': MemoryJobStore()
|
||||
}
|
||||
executors = {
|
||||
'default': AsyncIOExecutor()
|
||||
}
|
||||
job_defaults = {
|
||||
'coalesce': False,
|
||||
'max_instances': 1,
|
||||
'misfire_grace_time': 300 # 5 minutes
|
||||
}
|
||||
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
jobstores=jobstores,
|
||||
executors=executors,
|
||||
job_defaults=job_defaults,
|
||||
timezone='UTC'
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
"""Start the scheduler and load existing schedules"""
|
||||
if self.running:
|
||||
logger.warning("Scheduler is already running")
|
||||
return
|
||||
|
||||
try:
|
||||
self.scheduler.start()
|
||||
self.running = True
|
||||
logger.info("✅ NFOGuard Scheduler started successfully")
|
||||
|
||||
# Load existing scheduled scans from database
|
||||
await self.load_schedules()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the scheduler gracefully"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
try:
|
||||
self.scheduler.shutdown()
|
||||
self.running = False
|
||||
logger.info("✅ NFOGuard Scheduler stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping scheduler: {e}")
|
||||
|
||||
async def load_schedules(self):
|
||||
"""Load all enabled scheduled scans from database and add them to scheduler"""
|
||||
try:
|
||||
db = self.dependencies.get("db")
|
||||
if not db:
|
||||
logger.error("Database not available for loading schedules")
|
||||
return
|
||||
|
||||
# Get all enabled scheduled scans
|
||||
scheduled_scans = db.get_scheduled_scans(enabled_only=True)
|
||||
|
||||
for scan in scheduled_scans:
|
||||
await self.add_schedule(scan)
|
||||
|
||||
logger.info(f"Loaded {len(scheduled_scans)} scheduled scans")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load schedules: {e}")
|
||||
|
||||
async def add_schedule(self, scan: Dict[str, Any]):
|
||||
"""Add a scheduled scan to the scheduler"""
|
||||
try:
|
||||
job_id = f"scan_{scan['id']}"
|
||||
|
||||
# Remove existing job if it exists
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
|
||||
# Create cron trigger
|
||||
trigger = CronTrigger.from_crontab(scan['cron_expression'])
|
||||
|
||||
# Add job to scheduler
|
||||
self.scheduler.add_job(
|
||||
func=self._execute_scheduled_scan,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
args=[scan['id']],
|
||||
name=f"Scheduled Scan: {scan['name']}",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Update next run time in database
|
||||
next_run = self.scheduler.get_job(job_id).next_run_time
|
||||
if next_run:
|
||||
db = self.dependencies.get("db")
|
||||
if db:
|
||||
db.update_scan_next_run(scan['id'], next_run)
|
||||
|
||||
logger.info(f"✅ Added scheduled scan: {scan['name']} ({scan['cron_expression']})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add schedule for scan {scan['id']}: {e}")
|
||||
|
||||
async def remove_schedule(self, scan_id: int):
|
||||
"""Remove a scheduled scan from the scheduler"""
|
||||
try:
|
||||
job_id = f"scan_{scan_id}"
|
||||
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info(f"✅ Removed scheduled scan: {scan_id}")
|
||||
else:
|
||||
logger.warning(f"No job found for scan ID: {scan_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove schedule for scan {scan_id}: {e}")
|
||||
|
||||
async def update_schedule(self, scan: Dict[str, Any]):
|
||||
"""Update an existing scheduled scan"""
|
||||
try:
|
||||
# Remove old schedule and add new one
|
||||
await self.remove_schedule(scan['id'])
|
||||
if scan['enabled']:
|
||||
await self.add_schedule(scan)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update schedule for scan {scan['id']}: {e}")
|
||||
|
||||
async def _execute_scheduled_scan(self, scan_id: int):
|
||||
"""Execute a scheduled scan"""
|
||||
db = self.dependencies.get("db")
|
||||
if not db:
|
||||
logger.error(f"Database not available for executing scan {scan_id}")
|
||||
return
|
||||
|
||||
# Get scan details
|
||||
scan = db.get_scheduled_scan(scan_id)
|
||||
if not scan:
|
||||
logger.error(f"Scheduled scan {scan_id} not found")
|
||||
return
|
||||
|
||||
if not scan['enabled']:
|
||||
logger.info(f"Skipping disabled scan: {scan['name']}")
|
||||
return
|
||||
|
||||
execution_id = None
|
||||
try:
|
||||
logger.info(f"🚀 Starting scheduled scan: {scan['name']} (ID: {scan_id})")
|
||||
|
||||
# Create execution record
|
||||
execution_id = db.create_schedule_execution(
|
||||
schedule_id=scan_id,
|
||||
media_type=scan['media_type'],
|
||||
scan_mode=scan['scan_mode'],
|
||||
triggered_by="scheduler"
|
||||
)
|
||||
|
||||
# Update last run time
|
||||
db.update_scan_last_run(scan_id)
|
||||
|
||||
# Execute the actual scan
|
||||
result = await self._run_media_scan(scan, execution_id)
|
||||
|
||||
# Update execution with results
|
||||
db.update_schedule_execution(
|
||||
execution_id=execution_id,
|
||||
status="completed",
|
||||
items_processed=result.get('items_processed', 0),
|
||||
items_skipped=result.get('items_skipped', 0),
|
||||
items_failed=result.get('items_failed', 0),
|
||||
logs=result.get('logs', '')
|
||||
)
|
||||
|
||||
logger.info(f"✅ Completed scheduled scan: {scan['name']} - Processed: {result.get('items_processed', 0)}, Skipped: {result.get('items_skipped', 0)}, Failed: {result.get('items_failed', 0)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed scheduled scan: {scan['name']} - {e}")
|
||||
|
||||
if execution_id:
|
||||
db.update_schedule_execution(
|
||||
execution_id=execution_id,
|
||||
status="failed",
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def _run_media_scan(self, scan: Dict[str, Any], execution_id: int) -> Dict[str, Any]:
|
||||
"""Run the actual media scan based on scan configuration"""
|
||||
try:
|
||||
# Import scan functionality from existing modules
|
||||
from api.routes import run_tv_scan, run_movie_scan
|
||||
|
||||
media_type = scan['media_type']
|
||||
scan_mode = scan['scan_mode']
|
||||
specific_paths = scan.get('specific_paths', '').strip()
|
||||
|
||||
results = {
|
||||
'items_processed': 0,
|
||||
'items_skipped': 0,
|
||||
'items_failed': 0,
|
||||
'logs': []
|
||||
}
|
||||
|
||||
# Parse specific paths if provided
|
||||
paths = []
|
||||
if specific_paths:
|
||||
paths = [p.strip() for p in specific_paths.split(',') if p.strip()]
|
||||
|
||||
# Run TV scan if needed
|
||||
if media_type in ['tv', 'both']:
|
||||
logger.info(f"Running TV scan with mode: {scan_mode}")
|
||||
|
||||
# Use existing scan infrastructure
|
||||
tv_result = await self._execute_tv_scan(scan_mode, paths)
|
||||
|
||||
results['items_processed'] += tv_result.get('tv_series_processed', 0)
|
||||
results['items_skipped'] += tv_result.get('tv_series_skipped', 0)
|
||||
results['items_failed'] += tv_result.get('tv_series_failed', 0)
|
||||
results['logs'].append(f"TV Scan: {tv_result.get('message', 'Completed')}")
|
||||
|
||||
# Run movie scan if needed
|
||||
if media_type in ['movies', 'both']:
|
||||
logger.info(f"Running movie scan with mode: {scan_mode}")
|
||||
|
||||
# Use existing scan infrastructure
|
||||
movie_result = await self._execute_movie_scan(scan_mode, paths)
|
||||
|
||||
results['items_processed'] += movie_result.get('movies_processed', 0)
|
||||
results['items_skipped'] += movie_result.get('movies_skipped', 0)
|
||||
results['items_failed'] += movie_result.get('movies_failed', 0)
|
||||
results['logs'].append(f"Movie Scan: {movie_result.get('message', 'Completed')}")
|
||||
|
||||
results['logs'] = '\n'.join(results['logs'])
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in media scan execution: {e}")
|
||||
return {
|
||||
'items_processed': 0,
|
||||
'items_skipped': 0,
|
||||
'items_failed': 1,
|
||||
'logs': f"Scan failed: {str(e)}"
|
||||
}
|
||||
|
||||
async def _execute_tv_scan(self, scan_mode: str, specific_paths: list = None) -> Dict[str, Any]:
|
||||
"""Execute TV scan using existing infrastructure"""
|
||||
try:
|
||||
# This would integrate with the existing manual scan functionality
|
||||
# For now, return a placeholder result
|
||||
return {
|
||||
'tv_series_processed': 0,
|
||||
'tv_series_skipped': 0,
|
||||
'tv_series_failed': 0,
|
||||
'message': f'TV scan ({scan_mode}) - Integration pending'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"TV scan execution failed: {e}")
|
||||
return {
|
||||
'tv_series_processed': 0,
|
||||
'tv_series_skipped': 0,
|
||||
'tv_series_failed': 1,
|
||||
'message': f'TV scan failed: {str(e)}'
|
||||
}
|
||||
|
||||
async def _execute_movie_scan(self, scan_mode: str, specific_paths: list = None) -> Dict[str, Any]:
|
||||
"""Execute movie scan using existing infrastructure"""
|
||||
try:
|
||||
# This would integrate with the existing manual scan functionality
|
||||
# For now, return a placeholder result
|
||||
return {
|
||||
'movies_processed': 0,
|
||||
'movies_skipped': 0,
|
||||
'movies_failed': 0,
|
||||
'message': f'Movie scan ({scan_mode}) - Integration pending'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Movie scan execution failed: {e}")
|
||||
return {
|
||||
'movies_processed': 0,
|
||||
'movies_skipped': 0,
|
||||
'movies_failed': 1,
|
||||
'message': f'Movie scan failed: {str(e)}'
|
||||
}
|
||||
|
||||
async def run_manual_scan(self, scan_id: int) -> Dict[str, Any]:
|
||||
"""Manually trigger a scheduled scan"""
|
||||
try:
|
||||
db = self.dependencies.get("db")
|
||||
scan = db.get_scheduled_scan(scan_id)
|
||||
|
||||
if not scan:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Scheduled scan not found'
|
||||
}
|
||||
|
||||
# Execute the scan in the background
|
||||
asyncio.create_task(self._execute_scheduled_scan(scan_id))
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"Manual execution of '{scan['name']}' started"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run manual scan {scan_id}: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_job_status(self, scan_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get the status of a scheduled job"""
|
||||
try:
|
||||
job_id = f"scan_{scan_id}"
|
||||
job = self.scheduler.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job status for scan {scan_id}: {e}")
|
||||
return None
|
||||
|
||||
def list_jobs(self) -> list:
|
||||
"""List all scheduled jobs"""
|
||||
try:
|
||||
jobs = []
|
||||
for job in self.scheduler.get_jobs():
|
||||
jobs.append({
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
})
|
||||
return jobs
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list jobs: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
scheduler_instance: Optional[NFOGuardScheduler] = None
|
||||
|
||||
|
||||
async def get_scheduler(dependencies: Dict[str, Any]) -> NFOGuardScheduler:
|
||||
"""Get or create the global scheduler instance"""
|
||||
global scheduler_instance
|
||||
|
||||
if scheduler_instance is None:
|
||||
scheduler_instance = NFOGuardScheduler(dependencies)
|
||||
await scheduler_instance.start()
|
||||
|
||||
return scheduler_instance
|
||||
|
||||
|
||||
async def shutdown_scheduler():
|
||||
"""Shutdown the global scheduler instance"""
|
||||
global scheduler_instance
|
||||
|
||||
if scheduler_instance:
|
||||
await scheduler_instance.stop()
|
||||
scheduler_instance = None
|
||||
+2
-2
@@ -29,7 +29,7 @@ def create_web_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.9.0-fixes-only-files",
|
||||
version="2.7.0-web",
|
||||
docs_url=None, # Disable docs in production
|
||||
redoc_url=None
|
||||
)
|
||||
@@ -94,7 +94,7 @@ def setup_static_files(app: FastAPI) -> None:
|
||||
"status": "healthy",
|
||||
"service": "nfoguard-web",
|
||||
"timestamp": time.time(),
|
||||
"version": "2.9.0-fixes-only-files"
|
||||
"version": "2.8.0-web"
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -843,60 +843,3 @@ body {
|
||||
.d-flex { display: flex; }
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
|
||||
/* Manual Scan Styles */
|
||||
.scan-status {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--light-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.scan-info span:first-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.scan-info span:last-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
+2
-80
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NFOGuard - Database Management</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=manual-scan-ui">
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
@@ -327,51 +327,6 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
||||
<p>Scan specific folders or perform full library scans</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Mode:</label>
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Specific Path (Optional):</label>
|
||||
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
|
||||
<small>Leave empty to scan entire library</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
<div id="scan-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="scan-current-operation">Initializing...</span>
|
||||
<span id="scan-progress-text">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||
<p>View detailed database information</p>
|
||||
@@ -382,39 +337,6 @@
|
||||
<i class="fas fa-sync"></i> Refresh Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-upload"></i> Populate Database</h3>
|
||||
<p>Bulk import data from Radarr/Sonarr into NFOGuard database</p>
|
||||
<form id="populate-form">
|
||||
<div class="form-group">
|
||||
<label>Media Type:</label>
|
||||
<select id="populate-media-type" required>
|
||||
<option value="both">Movies & TV Shows</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Population
|
||||
</button>
|
||||
</form>
|
||||
<div id="populate-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="populate-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="populate-current-operation">Running...</span>
|
||||
<span id="populate-progress-text">In Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="populate-results" class="stats-display" style="margin-top: 10px;"></div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopPopulatePolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -482,6 +404,6 @@
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js?v=manual-scan-ui"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-465
@@ -76,8 +76,6 @@ function initializeEventListeners() {
|
||||
// Forms
|
||||
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
|
||||
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
|
||||
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
|
||||
document.getElementById('populate-form').addEventListener('submit', handlePopulateDatabase);
|
||||
}
|
||||
|
||||
// API calls
|
||||
@@ -505,21 +503,10 @@ function showEpisodesModal(data) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<button id="bulk-select-all" class="btn btn-sm btn-secondary" onclick="toggleSelectAll()">
|
||||
<i class="fas fa-check-square"></i> Select All
|
||||
</button>
|
||||
<button id="bulk-delete-selected" class="btn btn-sm btn-danger" onclick="bulkDeleteSelected()" style="margin-left: 10px;" disabled>
|
||||
<i class="fas fa-trash"></i> Delete Selected (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="40px">
|
||||
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
|
||||
</th>
|
||||
<th>Episode</th>
|
||||
<th>Aired</th>
|
||||
<th>Date Added</th>
|
||||
@@ -542,10 +529,7 @@ function showEpisodesModal(data) {
|
||||
`<td>${dateadded}</td>`;
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}" data-has-date="${!missingDate}" data-imdb="${data.series.imdb_id}" data-season="${episode.season}" data-episode="${episode.episode}">
|
||||
<td>
|
||||
<input type="checkbox" class="episode-checkbox" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
<tr class="${rowClass}" data-has-date="${!missingDate}">
|
||||
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
|
||||
<td>${episode.aired || '-'}</td>
|
||||
${dateCell}
|
||||
@@ -1452,169 +1436,6 @@ async function checkAuthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Scan Functions
|
||||
async function handleManualScan(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const scanType = document.getElementById('scan-type').value;
|
||||
const scanMode = document.getElementById('scan-mode').value;
|
||||
const scanPath = document.getElementById('scan-path').value.trim();
|
||||
|
||||
// Validate inputs
|
||||
if (!scanType || !scanMode) {
|
||||
showToast('❌ Please select scan type and mode', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
scan_type: scanType,
|
||||
scan_mode: scanMode
|
||||
});
|
||||
|
||||
if (scanPath) {
|
||||
params.append('path', scanPath);
|
||||
}
|
||||
|
||||
try {
|
||||
// Show scan status
|
||||
showScanStatus();
|
||||
|
||||
// Start the scan
|
||||
showToast('🚀 Starting manual scan...', 'info');
|
||||
const response = await fetch(`/manual/scan?${params}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showToast('✅ Scan started successfully', 'success');
|
||||
// Start polling for status
|
||||
startScanPolling();
|
||||
} else {
|
||||
showToast(`ℹ️ ${result.message || 'Scan completed'}`, 'info');
|
||||
hideScanStatus();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Manual scan failed:', error);
|
||||
showToast(`❌ Scan failed: ${error.message}`, 'error');
|
||||
hideScanStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function showScanStatus() {
|
||||
const scanStatus = document.getElementById('scan-status');
|
||||
const progressBar = document.getElementById('scan-progress-bar');
|
||||
const operationText = document.getElementById('scan-current-operation');
|
||||
const progressText = document.getElementById('scan-progress-text');
|
||||
|
||||
// Reset and show
|
||||
progressBar.style.width = '0%';
|
||||
operationText.textContent = 'Initializing scan...';
|
||||
progressText.textContent = '0%';
|
||||
scanStatus.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideScanStatus() {
|
||||
document.getElementById('scan-status').style.display = 'none';
|
||||
if (window.scanPollingInterval) {
|
||||
clearInterval(window.scanPollingInterval);
|
||||
window.scanPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopScanPolling() {
|
||||
hideScanStatus();
|
||||
}
|
||||
|
||||
function startScanPolling() {
|
||||
// Poll every 2 seconds for scan status
|
||||
window.scanPollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/scan/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get scan status');
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updateScanProgress(status);
|
||||
|
||||
// Stop polling if scan is complete
|
||||
if (!status.scanning) {
|
||||
stopScanPolling();
|
||||
showToast('✅ Scan completed!', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to poll scan status:', error);
|
||||
// Don't show error toast repeatedly, just stop polling
|
||||
stopScanPolling();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateScanProgress(status) {
|
||||
const progressBar = document.getElementById('scan-progress-bar');
|
||||
const operationText = document.getElementById('scan-current-operation');
|
||||
const progressText = document.getElementById('scan-progress-text');
|
||||
|
||||
if (!status.scanning) {
|
||||
progressBar.style.width = '100%';
|
||||
operationText.textContent = 'Scan completed';
|
||||
progressText.textContent = '100%';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate overall progress
|
||||
let totalProgress = 0;
|
||||
let progressDetails = '';
|
||||
|
||||
if (status.scan_type === 'both' || status.scan_type === 'tv') {
|
||||
const tvProgress = status.tv_series_total > 0 ?
|
||||
((status.tv_series_processed + status.tv_series_skipped) / status.tv_series_total) * 50 : 0;
|
||||
totalProgress += tvProgress;
|
||||
|
||||
if (status.tv_series_total > 0) {
|
||||
progressDetails += `TV: ${status.tv_series_processed + status.tv_series_skipped}/${status.tv_series_total} `;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.scan_type === 'both' || status.scan_type === 'movies') {
|
||||
const movieProgress = status.movies_total > 0 ?
|
||||
((status.movies_processed + status.movies_skipped) / status.movies_total) * 50 : 0;
|
||||
totalProgress += movieProgress;
|
||||
|
||||
if (status.movies_total > 0) {
|
||||
progressDetails += `Movies: ${status.movies_processed + status.movies_skipped}/${status.movies_total}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For single type scans, use full 100%
|
||||
if (status.scan_type !== 'both') {
|
||||
totalProgress *= 2;
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
progressBar.style.width = `${Math.min(totalProgress, 100)}%`;
|
||||
progressText.textContent = `${Math.round(totalProgress)}%`;
|
||||
|
||||
// Update operation text
|
||||
if (status.current_operation) {
|
||||
operationText.textContent = status.current_operation;
|
||||
} else if (status.current_item) {
|
||||
operationText.textContent = `Processing: ${status.current_item}`;
|
||||
} else {
|
||||
operationText.textContent = progressDetails || 'Scanning...';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (!confirm('Are you sure you want to logout?')) {
|
||||
return;
|
||||
@@ -1641,288 +1462,3 @@ async function logout() {
|
||||
showToast('❌ Logout error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk delete functions for TV episodes
|
||||
function toggleSelectAll() {
|
||||
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
const episodeCheckboxes = document.querySelectorAll('.episode-checkbox');
|
||||
|
||||
if (selectAllCheckbox && episodeCheckboxes.length > 0) {
|
||||
const shouldCheck = selectAllCheckbox.checked;
|
||||
episodeCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = shouldCheck;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
|
||||
const selectedCount = selectedCheckboxes.length;
|
||||
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
|
||||
const selectedCountSpan = document.getElementById('selected-count');
|
||||
|
||||
if (selectedCountSpan) {
|
||||
selectedCountSpan.textContent = selectedCount;
|
||||
}
|
||||
|
||||
if (bulkDeleteButton) {
|
||||
bulkDeleteButton.disabled = selectedCount === 0;
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
const allCheckboxes = document.querySelectorAll('.episode-checkbox');
|
||||
if (selectAllCheckbox && allCheckboxes.length > 0) {
|
||||
selectAllCheckbox.checked = selectedCount === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteSelected() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.episode-checkbox:checked');
|
||||
const selectedCount = selectedCheckboxes.length;
|
||||
|
||||
if (selectedCount === 0) {
|
||||
showToast('❌ No episodes selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${selectedCount} episode(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bulkDeleteButton = document.getElementById('bulk-delete-selected');
|
||||
const originalText = bulkDeleteButton.innerHTML;
|
||||
bulkDeleteButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
||||
bulkDeleteButton.disabled = true;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Process deletions
|
||||
for (const checkbox of selectedCheckboxes) {
|
||||
const row = checkbox.closest('tr');
|
||||
const imdbId = row.getAttribute('data-imdb');
|
||||
const season = parseInt(row.getAttribute('data-season'));
|
||||
const episode = parseInt(row.getAttribute('data-episode'));
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Remove the row from the table
|
||||
row.remove();
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
console.error(`Failed to delete S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
console.error(`Error deleting S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI
|
||||
updateEpisodeModalCounts();
|
||||
updateBulkDeleteButton();
|
||||
|
||||
// Reset button
|
||||
bulkDeleteButton.innerHTML = originalText;
|
||||
bulkDeleteButton.disabled = true;
|
||||
|
||||
// Show results
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
showToast(`✅ Successfully deleted ${successCount} episode(s)`, 'success');
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
showToast(`⚠️ Deleted ${successCount} episode(s), ${failCount} failed`, 'warning');
|
||||
} else {
|
||||
showToast(`❌ Failed to delete ${failCount} episode(s)`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Database Population Functions
|
||||
// ---------------------------
|
||||
|
||||
async function handlePopulateDatabase(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const mediaType = document.getElementById('populate-media-type').value;
|
||||
|
||||
// Validate input
|
||||
if (!mediaType) {
|
||||
showToast('❌ Please select media type', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm with user
|
||||
const confirmMsg = `Are you sure you want to populate the database with ${mediaType}? This will query Radarr/Sonarr and may take several minutes.`;
|
||||
if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show populate status
|
||||
showPopulateStatus();
|
||||
|
||||
// Start the population
|
||||
showToast('🚀 Starting database population...', 'info');
|
||||
const response = await fetch(`/admin/populate-database?media_type=${mediaType}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
showToast('✅ Population started successfully', 'success');
|
||||
// Start polling for status
|
||||
startPopulatePolling();
|
||||
} else {
|
||||
showToast(`ℹ️ ${result.message || 'Population completed'}`, 'info');
|
||||
hidePopulateStatus();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Database population failed:', error);
|
||||
showToast(`❌ Population failed: ${error.message}`, 'error');
|
||||
hidePopulateStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function showPopulateStatus() {
|
||||
const populateStatus = document.getElementById('populate-status');
|
||||
const progressBar = document.getElementById('populate-progress-bar');
|
||||
const operationText = document.getElementById('populate-current-operation');
|
||||
const progressText = document.getElementById('populate-progress-text');
|
||||
const resultsDiv = document.getElementById('populate-results');
|
||||
|
||||
populateStatus.style.display = 'block';
|
||||
progressBar.style.width = '0%';
|
||||
operationText.textContent = 'Initializing...';
|
||||
progressText.textContent = '0%';
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
|
||||
function hidePopulateStatus() {
|
||||
const populateStatus = document.getElementById('populate-status');
|
||||
if (populateStatus) {
|
||||
populateStatus.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function startPopulatePolling() {
|
||||
// Poll every 2 seconds for populate status
|
||||
window.populatePollingInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/populate/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get populate status');
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updatePopulateProgress(status);
|
||||
|
||||
// Stop polling if population is complete
|
||||
if (!status.running && status.completed) {
|
||||
stopPopulatePolling();
|
||||
showToast('✅ Database population completed!', 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to poll populate status:', error);
|
||||
// Don't show error toast repeatedly, just stop polling
|
||||
stopPopulatePolling();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopPopulatePolling() {
|
||||
if (window.populatePollingInterval) {
|
||||
clearInterval(window.populatePollingInterval);
|
||||
window.populatePollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePopulateProgress(status) {
|
||||
const progressBar = document.getElementById('populate-progress-bar');
|
||||
const operationText = document.getElementById('populate-current-operation');
|
||||
const progressText = document.getElementById('populate-progress-text');
|
||||
const resultsDiv = document.getElementById('populate-results');
|
||||
|
||||
if (status.error) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.style.backgroundColor = '#e74c3c';
|
||||
operationText.textContent = 'Error occurred';
|
||||
progressText.textContent = 'Failed';
|
||||
resultsDiv.innerHTML = `<p style="color: #e74c3c;">Error: ${status.error}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.running && status.completed) {
|
||||
progressBar.style.width = '100%';
|
||||
operationText.textContent = 'Population completed';
|
||||
progressText.textContent = '100%';
|
||||
|
||||
// Display results
|
||||
let resultsHTML = '<h4>Population Results:</h4>';
|
||||
|
||||
if (status.movies && status.movies.stats) {
|
||||
const m = status.movies.stats;
|
||||
resultsHTML += `
|
||||
<div style="margin: 10px 0;">
|
||||
<strong>Movies:</strong><br>
|
||||
Total: ${m.total || 0} | Added: ${m.added || 0} | Skipped: ${m.skipped || 0} | Errors: ${m.errors || 0}<br>
|
||||
Duration: ${m.duration ? m.duration.toFixed(2) : 0}s
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (status.tv && status.tv.stats) {
|
||||
const t = status.tv.stats;
|
||||
resultsHTML += `
|
||||
<div style="margin: 10px 0;">
|
||||
<strong>TV Shows:</strong><br>
|
||||
Series: ${t.total_series || 0} | Episodes: ${t.total_episodes || 0}<br>
|
||||
Added: ${t.added || 0} | Skipped: ${t.skipped || 0} | Errors: ${t.errors || 0}<br>
|
||||
Duration: ${t.duration ? t.duration.toFixed(2) : 0}s
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = resultsHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update progress based on status
|
||||
let totalProgress = 0;
|
||||
let progressDetails = '';
|
||||
|
||||
if (status.movies && status.movies.status === 'running') {
|
||||
totalProgress = 50;
|
||||
progressDetails = 'Processing movies...';
|
||||
} else if (status.movies && status.movies.status === 'completed') {
|
||||
totalProgress = 50;
|
||||
progressDetails = 'Movies completed, processing TV...';
|
||||
}
|
||||
|
||||
if (status.tv && status.tv.status === 'running') {
|
||||
totalProgress = 75;
|
||||
progressDetails = 'Processing TV shows...';
|
||||
} else if (status.tv && status.tv.status === 'completed') {
|
||||
totalProgress = 100;
|
||||
progressDetails = 'Completed';
|
||||
}
|
||||
|
||||
progressBar.style.width = `${totalProgress}%`;
|
||||
operationText.textContent = progressDetails;
|
||||
progressText.textContent = `${totalProgress}%`;
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMDb ID Extraction Utilities
|
||||
Parses IMDb IDs from directory/file names (no file I/O)
|
||||
Phase 3: Replaces NFOManager for IMDb ID extraction only
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_imdb_from_path(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Extract IMDb ID from directory path or filename using regex patterns.
|
||||
Does NOT read any files - only parses the path string.
|
||||
|
||||
Supported patterns:
|
||||
- [imdb-tt1234567]
|
||||
- [tt1234567]
|
||||
- {imdb-tt1234567}
|
||||
- (imdb-tt1234567)
|
||||
- -tt1234567 (at end)
|
||||
- _tt1234567 (at end)
|
||||
|
||||
Args:
|
||||
path: Path object to parse
|
||||
|
||||
Returns:
|
||||
IMDb ID (e.g., "tt1234567") or None if not found
|
||||
"""
|
||||
path_str = str(path).lower()
|
||||
|
||||
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try standalone [ttXXXXXXX] format in brackets
|
||||
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_imdb_in_directory(directory: Path) -> Optional[str]:
|
||||
"""
|
||||
Find IMDb ID from directory name or filenames within the directory.
|
||||
Does NOT read file contents - only checks filenames.
|
||||
|
||||
Args:
|
||||
directory: Directory path to search
|
||||
|
||||
Returns:
|
||||
IMDb ID or None if not found
|
||||
"""
|
||||
# First try directory name itself
|
||||
imdb_id = parse_imdb_from_path(directory)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all filenames in the directory
|
||||
if directory.is_dir():
|
||||
for file_path in directory.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
+1
-75
@@ -10,49 +10,6 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
class SafeRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""A RotatingFileHandler that handles missing backup files gracefully"""
|
||||
|
||||
def doRollover(self):
|
||||
"""
|
||||
Override doRollover to handle missing backup files gracefully
|
||||
"""
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
|
||||
if self.backupCount > 0:
|
||||
# Remove the oldest backup if it exists
|
||||
oldest_backup = f"{self.baseFilename}.{self.backupCount}"
|
||||
if os.path.exists(oldest_backup):
|
||||
try:
|
||||
os.remove(oldest_backup)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Ignore if file doesn't exist or can't be removed
|
||||
|
||||
# Rename existing backups, skipping missing ones
|
||||
for i in range(self.backupCount - 1, 0, -1):
|
||||
sfn = f"{self.baseFilename}.{i}"
|
||||
dfn = f"{self.baseFilename}.{i + 1}"
|
||||
if os.path.exists(sfn):
|
||||
try:
|
||||
os.rename(sfn, dfn)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Skip if source doesn't exist or rename fails
|
||||
|
||||
# Rename the main log file
|
||||
dfn = f"{self.baseFilename}.1"
|
||||
if os.path.exists(self.baseFilename):
|
||||
try:
|
||||
os.rename(self.baseFilename, dfn)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Skip if main file doesn't exist
|
||||
|
||||
# Open the new log file
|
||||
if not self.delay:
|
||||
self.stream = self._open()
|
||||
|
||||
|
||||
class TimezoneAwareFormatter(logging.Formatter):
|
||||
"""Formatter that respects the container timezone"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -93,13 +50,7 @@ def _setup_file_logging():
|
||||
logger = logging.getLogger("NFOGuard")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Clear any existing handlers to avoid duplicates
|
||||
logger.handlers.clear()
|
||||
|
||||
# Try to set up file logging
|
||||
file_logging_enabled = False
|
||||
try:
|
||||
file_handler = SafeRotatingFileHandler(
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||
)
|
||||
|
||||
@@ -108,31 +59,6 @@ def _setup_file_logging():
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
file_logging_enabled = True
|
||||
except Exception as e:
|
||||
# If RotatingFileHandler fails, try regular FileHandler
|
||||
try:
|
||||
file_handler = logging.FileHandler(log_dir / "nfoguard.log")
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
file_logging_enabled = True
|
||||
except Exception as e2:
|
||||
# File logging not available (e.g., read-only filesystem)
|
||||
# Fall back to console-only logging silently
|
||||
pass
|
||||
|
||||
# If file logging failed, ensure console handler is added
|
||||
if not file_logging_enabled:
|
||||
console_handler = logging.StreamHandler()
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
@@ -10,14 +10,12 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from config.settings import config
|
||||
from utils.logging import _log
|
||||
from utils.imdb_utils import find_imdb_in_directory, parse_imdb_from_path # Phase 3: Replaced NFOManager
|
||||
|
||||
|
||||
class WebhookBatcher:
|
||||
"""Batches webhook events to avoid processing storms"""
|
||||
|
||||
def __init__(self, nfo_manager=None):
|
||||
# nfo_manager parameter kept for backward compatibility but no longer used (Phase 3)
|
||||
self.pending: Dict[str, Dict] = {}
|
||||
self.timers: Dict[str, threading.Timer] = {}
|
||||
self.processing: Set[str] = set()
|
||||
@@ -26,6 +24,8 @@ class WebhookBatcher:
|
||||
# Will be set by the application when processors are available
|
||||
self.tv_processor = None
|
||||
self.movie_processor = None
|
||||
# NFO manager for comprehensive IMDb detection
|
||||
self.nfo_manager = nfo_manager
|
||||
|
||||
def set_processors(self, tv_processor, movie_processor):
|
||||
"""Set the processor instances"""
|
||||
@@ -85,8 +85,9 @@ class WebhookBatcher:
|
||||
if media_type == 'movie':
|
||||
expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
|
||||
|
||||
# Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
|
||||
detected_imdb = find_imdb_in_directory(path_obj)
|
||||
# Use comprehensive IMDb detection (directory, filenames, NFO files)
|
||||
if self.nfo_manager:
|
||||
detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj)
|
||||
imdb_match = False
|
||||
if detected_imdb:
|
||||
# Compare with and without 'tt' prefix for flexibility
|
||||
@@ -94,18 +95,26 @@ class WebhookBatcher:
|
||||
imdb_match = True
|
||||
|
||||
if not imdb_match:
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in {path_str}")
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}")
|
||||
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
|
||||
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
|
||||
else:
|
||||
# Fallback to simple string search if nfo_manager not available
|
||||
if expected_imdb not in path_str.lower():
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)")
|
||||
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
|
||||
|
||||
# CRITICAL: Validate that the path contains the expected IMDb ID for TV shows
|
||||
if media_type == 'tv':
|
||||
expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key
|
||||
|
||||
# Use imdb_utils for IMDb detection (Phase 3: no NFO reading)
|
||||
detected_imdb = parse_imdb_from_path(path_obj)
|
||||
# Use comprehensive IMDb detection (directory, filenames, tvshow.nfo files)
|
||||
if self.nfo_manager:
|
||||
detected_imdb = self.nfo_manager.find_series_imdb_id(path_obj)
|
||||
imdb_match = False
|
||||
if detected_imdb:
|
||||
# Compare with and without 'tt' prefix for flexibility
|
||||
@@ -113,11 +122,18 @@ class WebhookBatcher:
|
||||
imdb_match = True
|
||||
|
||||
if not imdb_match:
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} but found {detected_imdb} in TV {path_str}")
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in TV path {path_str}")
|
||||
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
|
||||
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
|
||||
else:
|
||||
# Fallback to simple string search if nfo_manager not available
|
||||
if expected_imdb not in path_str.lower():
|
||||
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in TV path {path_str} (fallback mode)")
|
||||
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
|
||||
return
|
||||
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
|
||||
|
||||
if not self.tv_processor:
|
||||
_log("ERROR", "TV processor not available")
|
||||
|
||||
Reference in New Issue
Block a user