dev #66
@@ -120,3 +120,65 @@ class EpisodeResponse(BaseModel):
|
|||||||
series_path: str
|
series_path: str
|
||||||
season_name: str
|
season_name: str
|
||||||
episode_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]
|
||||||
+417
-38
@@ -15,6 +15,8 @@ from api.models import (
|
|||||||
SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
|
SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
|
||||||
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
|
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
|
||||||
)
|
)
|
||||||
|
# Import logging utility
|
||||||
|
from utils.logging import _log
|
||||||
# Web routes removed - handled by separate web container
|
# Web routes removed - handled by separate web container
|
||||||
|
|
||||||
# Global scan status tracking for detailed progress
|
# Global scan status tracking for detailed progress
|
||||||
@@ -50,7 +52,7 @@ async def _read_payload(request: Request) -> dict:
|
|||||||
return json.loads(form["payload"])
|
return json.loads(form["payload"])
|
||||||
return dict(form)
|
return dict(form)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed to read webhook payload: {e}") # Using print since _log is not available
|
_log("ERROR", f"Failed to read webhook payload: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
raise HTTPException(status_code=422, detail="Empty Sonarr payload")
|
raise HTTPException(status_code=422, detail="Empty Sonarr payload")
|
||||||
|
|
||||||
webhook = SonarrWebhook(**payload)
|
webhook = SonarrWebhook(**payload)
|
||||||
print(f"INFO: Received Sonarr webhook: {webhook.eventType}")
|
_log("INFO", f"Received Sonarr webhook: {webhook.eventType}")
|
||||||
|
|
||||||
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
|
if webhook.eventType not in ["Download", "Upgrade", "Rename"]:
|
||||||
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
return {"status": "ignored", "reason": f"Event type {webhook.eventType} not processed"}
|
||||||
@@ -86,7 +88,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
sonarr_path = series_info.get("path", "")
|
sonarr_path = series_info.get("path", "")
|
||||||
|
|
||||||
if not imdb_id:
|
if not imdb_id:
|
||||||
print(f"ERROR: No IMDb ID for series: {series_title}")
|
_log("ERROR", f"No IMDb ID for series: {series_title}")
|
||||||
return {"status": "error", "reason": "No IMDb ID"}
|
return {"status": "error", "reason": "No IMDb ID"}
|
||||||
|
|
||||||
# Find series path
|
# Find series path
|
||||||
@@ -97,7 +99,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
|
|
||||||
# Extract episode data for targeted processing
|
# Extract episode data for targeted processing
|
||||||
episodes_data = webhook.episodes or []
|
episodes_data = webhook.episodes or []
|
||||||
print(f"DEBUG: Initial episodes_data from webhook.episodes: {len(episodes_data)} episodes")
|
_log("DEBUG", f"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
|
# 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)
|
# This ensures targeted processing for single episode operations (Download, Rename, Upgrade)
|
||||||
@@ -137,7 +139,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
"title": episode_file.get("title")
|
"title": episode_file.get("title")
|
||||||
# Note: Not including dateAdded - we use database-first approach with Sonarr fallback
|
# Note: Not including dateAdded - we use database-first approach with Sonarr fallback
|
||||||
}]
|
}]
|
||||||
print(f"INFO: Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}")
|
_log("INFO", f"Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}")
|
||||||
else:
|
else:
|
||||||
print(f"DEBUG: Missing season/episode numbers in episodeFile for {webhook.eventType}")
|
print(f"DEBUG: Missing season/episode numbers in episodeFile for {webhook.eventType}")
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
processing_mode = config.tv_webhook_processing_mode
|
processing_mode = config.tv_webhook_processing_mode
|
||||||
if episodes_data and len(episodes_data) <= 3: # Single episode or small batch
|
if episodes_data and len(episodes_data) <= 3: # Single episode or small batch
|
||||||
processing_mode = "targeted"
|
processing_mode = "targeted"
|
||||||
print(f"INFO: Forcing targeted mode for {len(episodes_data)} episode(s)")
|
_log("INFO", f"Forcing targeted mode for {len(episodes_data)} episode(s)")
|
||||||
|
|
||||||
# Add to batch queue with TV-prefixed key to avoid movie conflicts
|
# Add to batch queue with TV-prefixed key to avoid movie conflicts
|
||||||
tv_batch_key = f"tv:{imdb_id}"
|
tv_batch_key = f"tv:{imdb_id}"
|
||||||
@@ -257,7 +259,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"}
|
return {"status": "accepted", "message": f"Sonarr webhook queued for {tv_batch_key}"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Sonarr webhook error: {e}")
|
_log("ERROR", f"Sonarr webhook error: {e}")
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
|
raise HTTPException(status_code=422, detail=f"Invalid webhook: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -268,8 +270,8 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = await _read_payload(request)
|
payload = await _read_payload(request)
|
||||||
print(f"INFO: Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
|
_log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
|
||||||
print(f"DEBUG: Full Radarr webhook payload: {payload}")
|
_log("DEBUG", f"Full Radarr webhook payload: {payload}")
|
||||||
|
|
||||||
# Filter supported event types (same as Sonarr: Download, Upgrade, Rename)
|
# Filter supported event types (same as Sonarr: Download, Upgrade, Rename)
|
||||||
event_type = payload.get('eventType', '')
|
event_type = payload.get('eventType', '')
|
||||||
@@ -279,29 +281,29 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
# Extract movie info
|
# Extract movie info
|
||||||
movie_data = payload.get("movie", {})
|
movie_data = payload.get("movie", {})
|
||||||
if not movie_data:
|
if not movie_data:
|
||||||
print("WARNING: No movie data in Radarr webhook")
|
_log("WARNING", "No movie data in Radarr webhook")
|
||||||
return {"status": "error", "message": "No movie data"}
|
return {"status": "error", "message": "No movie data"}
|
||||||
|
|
||||||
# Get IMDb ID for batching key
|
# Get IMDb ID for batching key
|
||||||
imdb_id = movie_data.get("imdbId", "").lower()
|
imdb_id = movie_data.get("imdbId", "").lower()
|
||||||
if not imdb_id:
|
if not imdb_id:
|
||||||
print("WARNING: No IMDb ID in Radarr webhook movie data")
|
_log("WARNING", "No IMDb ID in Radarr webhook movie data")
|
||||||
return {"status": "error", "message": "No IMDb ID"}
|
return {"status": "error", "message": "No IMDb ID"}
|
||||||
|
|
||||||
# Get movie path and map it
|
# Get movie path and map it
|
||||||
movie_path = movie_data.get("folderPath") or movie_data.get("path", "")
|
movie_path = movie_data.get("folderPath") or movie_data.get("path", "")
|
||||||
if not movie_path:
|
if not movie_path:
|
||||||
print("ERROR: No movie path in Radarr webhook")
|
_log("ERROR", "No movie path in Radarr webhook")
|
||||||
return {"status": "error", "message": "No movie path provided"}
|
return {"status": "error", "message": "No movie path provided"}
|
||||||
|
|
||||||
# Map the path to container path
|
# Map the path to container path
|
||||||
container_path = path_mapper.radarr_path_to_container_path(movie_path)
|
container_path = path_mapper.radarr_path_to_container_path(movie_path)
|
||||||
print(f"DEBUG: Mapped Radarr path {movie_path} -> {container_path}")
|
_log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}")
|
||||||
|
|
||||||
# CRITICAL: Verify the mapped path actually exists
|
# CRITICAL: Verify the mapped path actually exists
|
||||||
if not Path(container_path).exists():
|
if not Path(container_path).exists():
|
||||||
print(f"ERROR: RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
|
_log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
|
||||||
print(f"ERROR: This prevents processing wrong movies due to path mapping issues")
|
_log("ERROR", "This prevents processing wrong movies due to path mapping issues")
|
||||||
return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"}
|
return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"}
|
||||||
|
|
||||||
# Verify the path contains the expected IMDb ID
|
# Verify the path contains the expected IMDb ID
|
||||||
@@ -318,13 +320,13 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
|
|||||||
|
|
||||||
# Add to batch queue with movie-prefixed key to avoid TV conflicts
|
# Add to batch queue with movie-prefixed key to avoid TV conflicts
|
||||||
movie_batch_key = f"movie:{imdb_id}"
|
movie_batch_key = f"movie:{imdb_id}"
|
||||||
print(f"DEBUG: Adding Radarr webhook to batch: key={movie_batch_key}, movie_title={movie_data.get('title', 'Unknown')}")
|
_log("DEBUG", f"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")
|
batcher.add_webhook(movie_batch_key, movie_webhook_data, "movie")
|
||||||
|
|
||||||
return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"}
|
return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Radarr webhook error: {e}")
|
_log("ERROR", f"Radarr webhook error: {e}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@@ -339,8 +341,8 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
raise HTTPException(status_code=422, detail="Empty Maintainarr payload")
|
raise HTTPException(status_code=422, detail="Empty Maintainarr payload")
|
||||||
|
|
||||||
webhook = MaintainarrWebhook(**payload)
|
webhook = MaintainarrWebhook(**payload)
|
||||||
print(f"INFO: Received Maintainarr webhook: {webhook.notification_type}")
|
_log("INFO", f"Received Maintainarr webhook: {webhook.notification_type}")
|
||||||
print(f"DEBUG: Full Maintainarr webhook payload: {payload}")
|
_log("DEBUG", f"Full Maintainarr webhook payload: {payload}")
|
||||||
|
|
||||||
# Handle test notifications differently for debugging
|
# Handle test notifications differently for debugging
|
||||||
notification_type = webhook.notification_type or ""
|
notification_type = webhook.notification_type or ""
|
||||||
@@ -385,7 +387,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not imdb_id:
|
if not imdb_id:
|
||||||
print(f"WARNING: No IMDb ID found in Maintainarr webhook - Message: '{message}', Subject: '{subject}'")
|
_log("WARNING", f"No IMDb ID found in Maintainarr webhook - Message: '{message}', Subject: '{subject}'")
|
||||||
return {"status": "ignored", "reason": "No IMDb ID found in webhook payload"}
|
return {"status": "ignored", "reason": "No IMDb ID found in webhook payload"}
|
||||||
|
|
||||||
# Try to extract title from subject or message
|
# Try to extract title from subject or message
|
||||||
@@ -414,7 +416,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
elif movie:
|
elif movie:
|
||||||
media_type = "Movie"
|
media_type = "Movie"
|
||||||
else:
|
else:
|
||||||
print(f"INFO: Media {title} ({imdb_id}) not found in database")
|
_log("INFO", f"Media {title} ({imdb_id}) not found in database")
|
||||||
return {"status": "ignored", "reason": f"Media {imdb_id} not found in database"}
|
return {"status": "ignored", "reason": f"Media {imdb_id} not found in database"}
|
||||||
|
|
||||||
# Process deletion based on media type
|
# Process deletion based on media type
|
||||||
@@ -422,7 +424,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
removed_items = []
|
removed_items = []
|
||||||
|
|
||||||
if media_type == "Movie":
|
if media_type == "Movie":
|
||||||
print(f"INFO: Processing movie deletion for {title} ({imdb_id})")
|
_log("INFO", f"Processing movie deletion for {title} ({imdb_id})")
|
||||||
|
|
||||||
# Check if movie exists in database
|
# Check if movie exists in database
|
||||||
movie = db.get_movie_by_imdb(imdb_id)
|
movie = db.get_movie_by_imdb(imdb_id)
|
||||||
@@ -430,14 +432,14 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
if db.delete_movie(imdb_id):
|
if db.delete_movie(imdb_id):
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
removed_items.append(f"Movie: {title} ({imdb_id})")
|
removed_items.append(f"Movie: {title} ({imdb_id})")
|
||||||
print(f"SUCCESS: Removed movie {title} ({imdb_id}) from database")
|
_log("INFO", f"SUCCESS: Removed movie {title} ({imdb_id}) from database")
|
||||||
else:
|
else:
|
||||||
print(f"WARNING: Failed to remove movie {title} ({imdb_id}) from database")
|
_log("WARNING", f"Failed to remove movie {title} ({imdb_id}) from database")
|
||||||
else:
|
else:
|
||||||
print(f"INFO: Movie {title} ({imdb_id}) not found in database")
|
_log("INFO", f"Movie {title} ({imdb_id}) not found in database")
|
||||||
|
|
||||||
elif media_type == "Series":
|
elif media_type == "Series":
|
||||||
print(f"INFO: Processing series deletion for {title} ({imdb_id})")
|
_log("INFO", f"Processing series deletion for {title} ({imdb_id})")
|
||||||
|
|
||||||
# Check if series exists in database
|
# Check if series exists in database
|
||||||
series = db.get_series_by_imdb(imdb_id)
|
series = db.get_series_by_imdb(imdb_id)
|
||||||
@@ -453,11 +455,11 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
if db.delete_series(imdb_id):
|
if db.delete_series(imdb_id):
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
removed_items.append(f"Series: {title} ({imdb_id})")
|
removed_items.append(f"Series: {title} ({imdb_id})")
|
||||||
print(f"SUCCESS: Removed series {title} ({imdb_id}) from database")
|
_log("INFO", f"SUCCESS: Removed series {title} ({imdb_id}) from database")
|
||||||
else:
|
else:
|
||||||
print(f"WARNING: Failed to remove series {title} ({imdb_id}) from database")
|
_log("WARNING", f"Failed to remove series {title} ({imdb_id}) from database")
|
||||||
else:
|
else:
|
||||||
print(f"INFO: Series {title} ({imdb_id}) not found in database")
|
_log("INFO", f"Series {title} ({imdb_id}) not found in database")
|
||||||
|
|
||||||
# Log the cleanup operation
|
# Log the cleanup operation
|
||||||
if removed_count > 0:
|
if removed_count > 0:
|
||||||
@@ -481,9 +483,9 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Maintainarr webhook error: {e}")
|
_log("ERROR", f"Maintainarr webhook error: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
print(f"ERROR: Traceback: {traceback.format_exc()}")
|
_log("ERROR", f"Traceback: {traceback.format_exc()}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@@ -495,7 +497,7 @@ async def _log_maintainarr_cleanup(event_type: str, media_type: str, title: str,
|
|||||||
log_message += f" from collection '{collection_name}'"
|
log_message += f" from collection '{collection_name}'"
|
||||||
log_message += f". Removed from database: {', '.join(removed_items)}"
|
log_message += f". Removed from database: {', '.join(removed_items)}"
|
||||||
|
|
||||||
print(f"INFO: {log_message}")
|
_log("INFO", log_message)
|
||||||
|
|
||||||
# Could extend this to write to a cleanup log file or database table
|
# Could extend this to write to a cleanup log file or database table
|
||||||
|
|
||||||
@@ -786,9 +788,27 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
movie_skipped = 0
|
movie_skipped = 0
|
||||||
movie_processed = 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 = []
|
paths_to_scan = []
|
||||||
if path:
|
if path:
|
||||||
paths_to_scan = [Path(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}")
|
||||||
else:
|
else:
|
||||||
if scan_type in ["both", "tv"]:
|
if scan_type in ["both", "tv"]:
|
||||||
paths_to_scan.extend(config.tv_paths)
|
paths_to_scan.extend(config.tv_paths)
|
||||||
@@ -796,12 +816,17 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
paths_to_scan.extend(config.movie_paths)
|
paths_to_scan.extend(config.movie_paths)
|
||||||
|
|
||||||
for scan_path in paths_to_scan:
|
for scan_path in paths_to_scan:
|
||||||
|
print(f"DEBUG: Checking scan_path: {scan_path}, exists: {scan_path.exists()}")
|
||||||
if not scan_path.exists():
|
if not scan_path.exists():
|
||||||
|
print(f"DEBUG: Path does not exist, skipping: {scan_path}")
|
||||||
continue
|
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):
|
if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path):
|
||||||
# Handle specific season/episode path
|
# Handle specific season/episode path
|
||||||
|
print(f"DEBUG: Entered TV processing branch")
|
||||||
if path and scan_path.name.lower().startswith('season'):
|
if path and scan_path.name.lower().startswith('season'):
|
||||||
|
print(f"DEBUG: Taking season processing path")
|
||||||
# Single season processing
|
# Single season processing
|
||||||
series_path = scan_path.parent
|
series_path = scan_path.parent
|
||||||
tv_processor_obj = dependencies.get("tv_processor")
|
tv_processor_obj = dependencies.get("tv_processor")
|
||||||
@@ -814,6 +839,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed processing season {scan_path}: {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'):
|
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
|
# Single episode processing
|
||||||
season_path = scan_path.parent
|
season_path = scan_path.parent
|
||||||
series_path = season_path.parent
|
series_path = season_path.parent
|
||||||
@@ -827,15 +853,19 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed processing episode {scan_path}: {e}")
|
print(f"ERROR: Failed processing episode {scan_path}: {e}")
|
||||||
else:
|
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)
|
# Check if this path itself is a series (has IMDb ID in directory name or NFO files)
|
||||||
tv_processor_obj = dependencies.get("tv_processor")
|
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
|
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
||||||
shutdown_event = dependencies.get("shutdown_event")
|
shutdown_event = dependencies.get("shutdown_event")
|
||||||
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client, shutdown_event):
|
imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client, shutdown_event)
|
||||||
|
print(f"DEBUG: Manual scan IMDb detection for {scan_path}: {imdb_id}")
|
||||||
|
if imdb_id:
|
||||||
try:
|
try:
|
||||||
# Determine force_scan based on scan mode
|
# Determine force_scan based on scan mode
|
||||||
force_scan = (scan_mode == "full")
|
force_scan = (scan_mode == "full")
|
||||||
result = tv_processor.process_series(scan_path, force_scan=force_scan)
|
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)
|
||||||
tv_series_total += 1
|
tv_series_total += 1
|
||||||
if result == "skipped":
|
if result == "skipped":
|
||||||
tv_series_skipped += 1
|
tv_series_skipped += 1
|
||||||
@@ -900,7 +930,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
try:
|
try:
|
||||||
# Determine force_scan based on scan mode
|
# Determine force_scan based on scan mode
|
||||||
force_scan = (scan_mode == "full")
|
force_scan = (scan_mode == "full")
|
||||||
result = tv_processor.process_series(item, force_scan=force_scan)
|
result = tv_processor.process_series(item, force_scan=force_scan, scan_mode=scan_mode)
|
||||||
tv_series_total += 1
|
tv_series_total += 1
|
||||||
if result == "skipped":
|
if result == "skipped":
|
||||||
tv_series_skipped += 1
|
tv_series_skipped += 1
|
||||||
@@ -973,7 +1003,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
|||||||
# Determine force_scan based on scan mode
|
# Determine force_scan based on scan mode
|
||||||
force_scan = (scan_mode == "full")
|
force_scan = (scan_mode == "full")
|
||||||
shutdown_event = dependencies.get("shutdown_event")
|
shutdown_event = dependencies.get("shutdown_event")
|
||||||
result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
|
result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, scan_mode=scan_mode, shutdown_event=shutdown_event)
|
||||||
movie_total += 1
|
movie_total += 1
|
||||||
if result == "skipped":
|
if result == "skipped":
|
||||||
movie_skipped += 1
|
movie_skipped += 1
|
||||||
@@ -2170,7 +2200,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}"}
|
return {"success": False, "message": f"Series directory not found for {imdb_id}"}
|
||||||
|
|
||||||
# Get season directory
|
# Get season directory
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
season_dir = series_path / config.get_season_dir_name(season)
|
||||||
if not season_dir.exists():
|
if not season_dir.exists():
|
||||||
return {"success": False, "message": f"Season directory not found: {season_dir}"}
|
return {"success": False, "message": f"Season directory not found: {season_dir}"}
|
||||||
|
|
||||||
@@ -2193,6 +2223,321 @@ 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)}"}
|
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)}")
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app, dependencies: dict):
|
def register_routes(app, dependencies: dict):
|
||||||
"""
|
"""
|
||||||
Register all routes with the FastAPI app
|
Register all routes with the FastAPI app
|
||||||
@@ -2324,6 +2669,40 @@ def register_routes(app, dependencies: dict):
|
|||||||
async def _debug_tmdb_lookup(imdb_id: str):
|
async def _debug_tmdb_lookup(imdb_id: str):
|
||||||
return await debug_tmdb_lookup(imdb_id, dependencies)
|
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
|
# Include monitoring routes
|
||||||
from api.monitoring_routes import router as monitoring_router
|
from api.monitoring_routes import router as monitoring_router
|
||||||
app.include_router(monitoring_router)
|
app.include_router(monitoring_router)
|
||||||
|
|||||||
+325
-3
@@ -1425,8 +1425,8 @@ def register_web_routes(app, dependencies):
|
|||||||
# Create request with timeout (no body needed for query parameters)
|
# Create request with timeout (no body needed for query parameters)
|
||||||
req = urllib.request.Request(core_url, method='POST')
|
req = urllib.request.Request(core_url, method='POST')
|
||||||
|
|
||||||
# Make request with timeout
|
# Make request with timeout (increased for manual scans which can take several minutes)
|
||||||
with urllib.request.urlopen(req, timeout=30) as response:
|
with urllib.request.urlopen(req, timeout=300) as response:
|
||||||
response_data = response.read().decode('utf-8')
|
response_data = response.read().decode('utf-8')
|
||||||
return json.loads(response_data)
|
return json.loads(response_data)
|
||||||
|
|
||||||
@@ -1796,7 +1796,7 @@ async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_a
|
|||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
try:
|
try:
|
||||||
series_path = Path(ep['series_path'])
|
series_path = Path(ep['series_path'])
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=ep['season'])
|
season_dir = series_path / config.get_season_dir_name(ep['season'])
|
||||||
|
|
||||||
if not season_dir.exists():
|
if not season_dir.exists():
|
||||||
continue
|
continue
|
||||||
@@ -1898,3 +1898,325 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
}
|
||||||
@@ -173,6 +173,12 @@ class NFOGuardConfig:
|
|||||||
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
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()
|
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:
|
def _load_auth_settings(self) -> None:
|
||||||
"""Load web interface authentication settings"""
|
"""Load web interface authentication settings"""
|
||||||
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from utils.async_file_utils import (
|
|||||||
async_file_exists,
|
async_file_exists,
|
||||||
async_set_file_mtime,
|
async_set_file_mtime,
|
||||||
async_batch_nfo_operations,
|
async_batch_nfo_operations,
|
||||||
async_concurrent_episode_processing
|
async_concurrent_episode_processing,
|
||||||
|
aiofiles
|
||||||
)
|
)
|
||||||
from utils.nfo_patterns import (
|
from utils.nfo_patterns import (
|
||||||
create_basic_nfo_structure,
|
create_basic_nfo_structure,
|
||||||
@@ -24,6 +25,7 @@ from utils.nfo_patterns import (
|
|||||||
extract_imdb_id_from_text
|
extract_imdb_id_from_text
|
||||||
)
|
)
|
||||||
from utils.validation import validate_date_string
|
from utils.validation import validate_date_string
|
||||||
|
from utils.file_utils import VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
class AsyncNFOManager:
|
class AsyncNFOManager:
|
||||||
@@ -33,6 +35,54 @@ class AsyncNFOManager:
|
|||||||
self.manager_brand = manager_brand
|
self.manager_brand = manager_brand
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
|
|
||||||
|
async def _async_get_target_nfo_path(self, season_dir: Path, season: int, episode: int) -> Path:
|
||||||
|
"""
|
||||||
|
Get the target NFO path using video filename matching to prevent concatenation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season_dir: Path to season directory
|
||||||
|
season: Season number
|
||||||
|
episode: Episode number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to target NFO file (video-matching preferred, generic fallback)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Find video files in the season directory
|
||||||
|
video_files = []
|
||||||
|
if await async_file_exists(season_dir):
|
||||||
|
import re
|
||||||
|
try:
|
||||||
|
entries = await aiofiles.os.listdir(season_dir)
|
||||||
|
for entry in entries:
|
||||||
|
entry_path = season_dir / entry
|
||||||
|
if await aiofiles.os.path.isfile(entry_path):
|
||||||
|
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
# Parse episode info from filename
|
||||||
|
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', entry)
|
||||||
|
if match:
|
||||||
|
s, e = int(match.group(1)), int(match.group(2))
|
||||||
|
if s == season and e == episode:
|
||||||
|
# Found matching video file - use its name for NFO
|
||||||
|
target_nfo = season_dir / f"{entry_path.stem}.nfo"
|
||||||
|
if self.debug:
|
||||||
|
_log("DEBUG", f"Video-matching NFO path: {target_nfo.name}")
|
||||||
|
return target_nfo
|
||||||
|
except Exception as e:
|
||||||
|
if self.debug:
|
||||||
|
_log("WARNING", f"Failed to scan season directory {season_dir}: {e}")
|
||||||
|
|
||||||
|
# Fallback to generic filename if no matching video found
|
||||||
|
target_nfo = season_dir / f"S{season:02d}E{episode:02d}.nfo"
|
||||||
|
if self.debug:
|
||||||
|
_log("WARNING", f"No video file found for S{season:02d}E{episode:02d}, using generic: {target_nfo.name}")
|
||||||
|
return target_nfo
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to determine NFO path for S{season:02d}E{episode:02d}: {e}")
|
||||||
|
# Emergency fallback
|
||||||
|
return season_dir / f"S{season:02d}E{episode:02d}.nfo"
|
||||||
|
|
||||||
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Async extract IMDb ID from directory path or filename
|
Async extract IMDb ID from directory path or filename
|
||||||
@@ -184,7 +234,7 @@ class AsyncNFOManager:
|
|||||||
lock_metadata: bool = True
|
lock_metadata: bool = True
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Async create episode NFO file
|
Async create episode NFO file with proper video filename matching
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
season_dir: Path to season directory
|
season_dir: Path to season directory
|
||||||
@@ -199,8 +249,8 @@ class AsyncNFOManager:
|
|||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
|
# Use proper video filename matching to prevent NFO concatenation
|
||||||
nfo_path = season_dir / nfo_filename
|
nfo_path = await self._async_get_target_nfo_path(season_dir, season, episode)
|
||||||
|
|
||||||
# Prepare dates
|
# Prepare dates
|
||||||
dates = {}
|
dates = {}
|
||||||
|
|||||||
@@ -147,6 +147,48 @@ 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
|
# 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_imdb ON episodes(imdb_id)")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||||
@@ -155,6 +197,11 @@ 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_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_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_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):
|
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||||
"""Insert or update series record"""
|
"""Insert or update series record"""
|
||||||
with self.get_connection() as conn:
|
with self.get_connection() as conn:
|
||||||
@@ -673,6 +720,226 @@ class NFOGuardDatabase:
|
|||||||
|
|
||||||
return deleted_count > 0
|
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):
|
def close(self):
|
||||||
"""Close all database connections"""
|
"""Close all database connections"""
|
||||||
if hasattr(self._local, 'connection'):
|
if hasattr(self._local, 'connection'):
|
||||||
|
|||||||
+54
-2
@@ -249,12 +249,64 @@ class NFOManager:
|
|||||||
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
|
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
|
return result
|
||||||
|
|
||||||
except (ET.ParseError, Exception) as e:
|
except ET.ParseError as e:
|
||||||
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
|
# Handle malformed XML files gracefully (common with external NFO files)
|
||||||
|
print(f"🔍 NFO XML parse error (will try text fallback): {nfo_path.name}")
|
||||||
|
# Try text-based fallback for extracting NFOGuard data
|
||||||
|
return self._extract_nfoguard_data_text_fallback(nfo_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Unexpected error reading NFO file {nfo_path.name}: {e}")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _extract_nfoguard_data_text_fallback(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||||
|
"""Text-based fallback for extracting NFOGuard data from malformed XML files"""
|
||||||
|
try:
|
||||||
|
content = nfo_path.read_text(encoding='utf-8', errors='ignore')
|
||||||
|
|
||||||
|
# Look for NFOGuard elements using regex
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Check for lockdata=true first (NFOGuard marker)
|
||||||
|
if not re.search(r'<lockdata>true</lockdata>', content, re.IGNORECASE):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract dateadded
|
||||||
|
dateadded_match = re.search(r'<dateadded>([^<]+)</dateadded>', content, re.IGNORECASE)
|
||||||
|
if not dateadded_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dateadded = dateadded_match.group(1).strip()
|
||||||
|
if not dateadded:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"dateadded": dateadded,
|
||||||
|
"source": "nfo_file_existing" # Default source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract optional fields
|
||||||
|
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', content, re.IGNORECASE)
|
||||||
|
if premiered_match:
|
||||||
|
result["released"] = premiered_match.group(1).strip()
|
||||||
|
|
||||||
|
aired_match = re.search(r'<aired>([^<]+)</aired>', content, re.IGNORECASE)
|
||||||
|
if aired_match:
|
||||||
|
result["aired"] = aired_match.group(1).strip()
|
||||||
|
|
||||||
|
# Extract source from NFOGuard comment
|
||||||
|
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', content)
|
||||||
|
if source_match:
|
||||||
|
result["source"] = source_match.group(1).strip()
|
||||||
|
|
||||||
|
print(f"✅ Extracted NFOGuard data via text fallback: dateadded={result.get('dateadded', 'None')}, source={result['source']}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Text fallback extraction failed for {nfo_path.name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
|
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"""
|
"""Extract NFOGuard-managed dates from existing episode NFO file"""
|
||||||
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
|
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
|
||||||
|
|||||||
@@ -49,32 +49,10 @@ body {
|
|||||||
text-align: center;
|
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 {
|
.header-content h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content h1 i {
|
.header-content h1 i {
|
||||||
@@ -84,28 +62,6 @@ body {
|
|||||||
.header-content p {
|
.header-content p {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
font-size: 1rem;
|
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 */
|
/* Authentication Status */
|
||||||
@@ -888,454 +844,59 @@ body {
|
|||||||
.justify-content-between { justify-content: space-between; }
|
.justify-content-between { justify-content: space-between; }
|
||||||
.align-items-center { align-items: center; }
|
.align-items-center { align-items: center; }
|
||||||
|
|
||||||
/* Database Admin Tools Styles */
|
/* Manual Scan Styles */
|
||||||
.query-results {
|
.scan-status {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #f8f9fa;
|
background-color: var(--light-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-info {
|
.scan-progress {
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.query-info.success {
|
.progress-bar {
|
||||||
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%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
height: 1.5rem;
|
||||||
font-size: 0.875rem;
|
background-color: #e9ecef;
|
||||||
background: white;
|
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
overflow: hidden;
|
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;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tools {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tools .btn {
|
.scan-info span:first-child {
|
||||||
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);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading::before {
|
.scan-info span:last-child {
|
||||||
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;
|
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);
|
color: var(--primary-color);
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommendation-box {
|
.btn-sm {
|
||||||
background-color: #fff3cd;
|
padding: 0.375rem 0.75rem;
|
||||||
border: 1px solid #ffeaa7;
|
font-size: 0.875rem;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+264
-146
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NFOGuard - Database Management</title>
|
<title>NFOGuard - Database Management</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=2.8.2-20241026-nfo-table-redesign">
|
<link rel="stylesheet" href="/static/css/styles.css?v=manual-scan-ui">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,12 +12,8 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-logo">
|
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
|
||||||
<div class="header-text">
|
<p>Database Management & Reporting</p>
|
||||||
<h1>NFOGuard</h1>
|
|
||||||
<p>Database Management & Reporting</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-status" id="auth-status" style="display: none;">
|
<div class="auth-status" id="auth-status" style="display: none;">
|
||||||
<span class="auth-user">
|
<span class="auth-user">
|
||||||
@@ -40,6 +36,9 @@
|
|||||||
<button class="nav-tab" data-tab="reports">
|
<button class="nav-tab" data-tab="reports">
|
||||||
<i class="fas fa-chart-bar"></i> Reports
|
<i class="fas fa-chart-bar"></i> Reports
|
||||||
</button>
|
</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">
|
<button class="nav-tab" data-tab="tools">
|
||||||
<i class="fas fa-tools"></i> Tools
|
<i class="fas fa-tools"></i> Tools
|
||||||
</button>
|
</button>
|
||||||
@@ -50,24 +49,6 @@
|
|||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
<div class="tab-content active" id="dashboard">
|
<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="dashboard-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon movies">
|
<div class="stat-icon movies">
|
||||||
@@ -309,37 +290,114 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Tools Tab -->
|
||||||
<div class="tab-content" id="tools">
|
<div class="tab-content" id="tools">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<h2><i class="fas fa-tools"></i> Database Tools</h2>
|
<h2><i class="fas fa-tools"></i> Database Tools</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
<span class="progress-text" id="scan-progress-text">Initializing...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tools-grid">
|
<div class="tools-grid">
|
||||||
<!-- Manual Scan Tools -->
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>From Source:</label>
|
||||||
|
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
|
||||||
|
</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="tool-card">
|
<div class="tool-card">
|
||||||
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
||||||
<p>Run manual scans on your media library</p>
|
<p>Scan specific folders or perform full library scans</p>
|
||||||
<form id="manual-scan-form">
|
<form id="manual-scan-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Scan Type:</label>
|
<label>Scan Type:</label>
|
||||||
<select id="scan-type" required>
|
<select id="scan-type" required>
|
||||||
<option value="both">Both (Movies & TV)</option>
|
<option value="both">TV Shows & Movies</option>
|
||||||
|
<option value="tv">TV Shows Only</option>
|
||||||
<option value="movies">Movies Only</option>
|
<option value="movies">Movies Only</option>
|
||||||
<option value="tv">TV Series Only</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -347,39 +405,34 @@
|
|||||||
<select id="scan-mode" required>
|
<select id="scan-mode" required>
|
||||||
<option value="smart">Smart (Recommended)</option>
|
<option value="smart">Smart (Recommended)</option>
|
||||||
<option value="full">Full Scan</option>
|
<option value="full">Full Scan</option>
|
||||||
<option value="incomplete">Incomplete Items Only</option>
|
<option value="incomplete">Incomplete Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary">
|
<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
|
<i class="fas fa-play"></i> Start Scan
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<div id="scan-status" class="scan-status" style="display: none;">
|
||||||
|
<div class="scan-progress">
|
||||||
<div class="tool-card">
|
<div class="progress-bar">
|
||||||
<h3><i class="fas fa-folder"></i> Custom Directory Scan</h3>
|
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||||
<p>Scan a specific directory or path</p>
|
</div>
|
||||||
<form id="custom-scan-form">
|
<div class="scan-info">
|
||||||
<div class="form-group">
|
<span id="scan-current-operation">Initializing...</span>
|
||||||
<label>Directory Path:</label>
|
<span id="scan-progress-text">0%</span>
|
||||||
<input type="text" id="scan-path" placeholder="/media/Movies/specific-folder" />
|
</div>
|
||||||
<small>Enter the full path to scan (will be auto-formatted)</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||||
<label>Scan Type:</label>
|
<i class="fas fa-times"></i> Hide Status
|
||||||
<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 type="submit" class="btn-primary">
|
|
||||||
<i class="fas fa-search"></i> Scan Directory
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||||
<p>View detailed database information</p>
|
<p>View detailed database information</p>
|
||||||
@@ -390,85 +443,6 @@
|
|||||||
<i class="fas fa-sync"></i> Refresh Stats
|
<i class="fas fa-sync"></i> Refresh Stats
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Database Query Tool -->
|
|
||||||
<div class="tool-card">
|
|
||||||
<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>Quick Queries:</label>
|
|
||||||
<select id="quick-query-select" onchange="loadQuickQuery()">
|
|
||||||
<option value="">Select a predefined query...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<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="query-results" class="query-results" style="display: none;">
|
|
||||||
<h4>Query Results:</h4>
|
|
||||||
<div id="query-results-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -533,9 +507,153 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Toast Notifications -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script src="/static/js/app.js?v=3.0.1-nfo-table-redesign"></script>
|
<script src="/static/js/app.js?v=scheduled-scans-system"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+353
-836
File diff suppressed because it is too large
Load Diff
@@ -151,7 +151,7 @@ class MovieProcessor:
|
|||||||
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
|
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
|
||||||
return False, f"Error checking completion: {e}"
|
return False, f"Error checking completion: {e}"
|
||||||
|
|
||||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, shutdown_event=None) -> str:
|
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, scan_mode: str = "smart", shutdown_event=None) -> str:
|
||||||
"""Process a movie directory"""
|
"""Process a movie directory"""
|
||||||
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
|
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
|
||||||
if not imdb_id:
|
if not imdb_id:
|
||||||
@@ -165,8 +165,9 @@ class MovieProcessor:
|
|||||||
else:
|
else:
|
||||||
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
|
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
|
||||||
|
|
||||||
# Check if we should skip this movie (unless forced or webhook mode)
|
# Check if we should skip this movie (unless forced, webhook mode, or incomplete mode)
|
||||||
if not force_scan and not webhook_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":
|
||||||
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
|
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
|
||||||
if should_skip:
|
if should_skip:
|
||||||
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
|
||||||
@@ -196,6 +197,11 @@ class MovieProcessor:
|
|||||||
_log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
|
_log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
|
||||||
return "no_video_files"
|
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)
|
# TIER 1: Check database first (fastest - local lookup)
|
||||||
existing = self.db.get_movie_dates(imdb_id)
|
existing = self.db.get_movie_dates(imdb_id)
|
||||||
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
||||||
@@ -400,6 +406,141 @@ class MovieProcessor:
|
|||||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||||
return "processed"
|
return "processed"
|
||||||
|
|
||||||
|
def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
|
||||||
|
"""Process movie for incomplete mode: Check NFO files first for missing dateadded elements"""
|
||||||
|
_log("INFO", f"🔍 NFO-FIRST MODE: Checking movie for missing dateadded in NFO file: {movie_path.name}")
|
||||||
|
|
||||||
|
# Check for shutdown signal
|
||||||
|
if shutdown_event and shutdown_event.is_set():
|
||||||
|
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie NFO-first processing: {movie_path.name}")
|
||||||
|
return "shutdown"
|
||||||
|
|
||||||
|
nfo_path = movie_path / "movie.nfo"
|
||||||
|
|
||||||
|
# STEP 1: Check if NFO file exists and has dateadded
|
||||||
|
_log("DEBUG", f"STEP 1 - Checking NFO file for missing dateadded: {nfo_path}")
|
||||||
|
_log("INFO", f"🔍 NFO exists: {nfo_path.exists()}")
|
||||||
|
|
||||||
|
if nfo_path.exists():
|
||||||
|
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||||
|
_log("INFO", f"🔍 NFOGuard data extracted: {nfo_data}")
|
||||||
|
|
||||||
|
if nfo_data and nfo_data.get('dateadded'):
|
||||||
|
# NFO has dateadded - this movie is complete
|
||||||
|
_log("INFO", f"✅ NFO has dateadded={nfo_data['dateadded']}, movie marked as complete")
|
||||||
|
dateadded = nfo_data["dateadded"]
|
||||||
|
source = nfo_data["source"]
|
||||||
|
released = nfo_data.get("released")
|
||||||
|
|
||||||
|
# Cache NFO data in database for future lookups
|
||||||
|
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||||
|
|
||||||
|
# Update file mtimes if needed
|
||||||
|
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-complete]")
|
||||||
|
return "processed"
|
||||||
|
else:
|
||||||
|
# NFO exists but missing dateadded
|
||||||
|
_log("INFO", f"🔍 NFO exists but missing dateadded - needs DB/API lookup")
|
||||||
|
else:
|
||||||
|
# No NFO file found
|
||||||
|
_log("INFO", f"🔍 No NFO file found - needs DB/API lookup")
|
||||||
|
|
||||||
|
# STEP 2: For movies missing dateadded in NFO, check database
|
||||||
|
_log("DEBUG", f"STEP 2 - Checking database for missing dateadded")
|
||||||
|
existing = self.db.get_movie_dates(imdb_id)
|
||||||
|
|
||||||
|
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||||
|
# Found in database - use cached data and will add to NFO
|
||||||
|
_log("INFO", f"✅ Database has dateadded={existing['dateadded']} - will add to NFO")
|
||||||
|
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||||
|
|
||||||
|
# Convert datetime objects to strings for NFO manager
|
||||||
|
if hasattr(dateadded, 'isoformat'):
|
||||||
|
dateadded = dateadded.isoformat()
|
||||||
|
if released and hasattr(released, 'isoformat'):
|
||||||
|
released = released.isoformat()
|
||||||
|
|
||||||
|
# Create NFO with database data
|
||||||
|
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-to-nfo]")
|
||||||
|
return "processed"
|
||||||
|
|
||||||
|
# STEP 3: For movies still missing dateadded, query APIs
|
||||||
|
_log("DEBUG", f"STEP 3 - Querying APIs for missing dateadded")
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Handle TMDB ID fallback case
|
||||||
|
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||||
|
|
||||||
|
if is_tmdb_fallback:
|
||||||
|
# TMDB fallback processing
|
||||||
|
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
|
||||||
|
|
||||||
|
# Check for existing TMDB NFO date data
|
||||||
|
tmdb_data = self._extract_dates_from_tmdb_nfo(nfo_path)
|
||||||
|
if tmdb_data and tmdb_data.get('dateadded'):
|
||||||
|
dateadded = tmdb_data['dateadded']
|
||||||
|
released = tmdb_data.get('released')
|
||||||
|
source = "tmdb_nfo"
|
||||||
|
else:
|
||||||
|
# Use file modification time as fallback for TMDB
|
||||||
|
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||||
|
_log("INFO", f"Using file mtime as fallback for TMDB movie: {dateadded}")
|
||||||
|
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})")
|
||||||
|
else:
|
||||||
|
# Check Radarr NFO for premiered date
|
||||||
|
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
||||||
|
if radarr_premiered:
|
||||||
|
dateadded = radarr_premiered
|
||||||
|
source = "radarr_nfo"
|
||||||
|
released = radarr_premiered
|
||||||
|
_log("INFO", f"Got premiered date from Radarr NFO: {dateadded}")
|
||||||
|
else:
|
||||||
|
# Last resort: file modification time
|
||||||
|
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||||
|
_log("INFO", f"Using file mtime as last resort: {dateadded}")
|
||||||
|
|
||||||
|
# Save to database and create NFO
|
||||||
|
if dateadded:
|
||||||
|
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||||
|
|
||||||
|
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"🔍 NFO-FIRST COMPLETE: {movie_path.name} (source: {source})")
|
||||||
|
return "processed"
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
|
||||||
|
return "error"
|
||||||
|
|
||||||
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||||
"""Extract date information from TMDB-based NFO file"""
|
"""Extract date information from TMDB-based NFO file"""
|
||||||
if not nfo_path.exists():
|
if not nfo_path.exists():
|
||||||
|
|||||||
+153
-11
@@ -145,7 +145,7 @@ class TVProcessor:
|
|||||||
_log("ERROR", f"Error checking series completion for {imdb_id}: {e}")
|
_log("ERROR", f"Error checking series completion for {imdb_id}: {e}")
|
||||||
return False, f"Error checking completion: {e}"
|
return False, f"Error checking completion: {e}"
|
||||||
|
|
||||||
def process_series(self, series_path: Path, force_scan: bool = False) -> str:
|
def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart") -> str:
|
||||||
"""Process a TV series directory"""
|
"""Process a TV series directory"""
|
||||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||||
if not imdb_id:
|
if not imdb_id:
|
||||||
@@ -155,7 +155,8 @@ class TVProcessor:
|
|||||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||||
|
|
||||||
# Fast check first - avoid expensive filesystem scan if possible
|
# Fast check first - avoid expensive filesystem scan if possible
|
||||||
if not force_scan:
|
# Skip fast optimization for incomplete mode since we need to check NFO files first
|
||||||
|
if not force_scan and scan_mode != "incomplete":
|
||||||
should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name)
|
should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name)
|
||||||
if should_skip_fast:
|
if should_skip_fast:
|
||||||
_log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}")
|
_log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}")
|
||||||
@@ -194,7 +195,7 @@ class TVProcessor:
|
|||||||
|
|
||||||
# Create NFO
|
# Create NFO
|
||||||
if config.manage_nfo:
|
if config.manage_nfo:
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
season_dir = series_path / config.get_season_dir_name(season)
|
||||||
self.nfo_manager.create_episode_nfo(
|
self.nfo_manager.create_episode_nfo(
|
||||||
season_dir,
|
season_dir,
|
||||||
season, episode, aired, dateadded, source, config.lock_metadata
|
season, episode, aired, dateadded, source, config.lock_metadata
|
||||||
@@ -226,12 +227,17 @@ class TVProcessor:
|
|||||||
return extract_title_from_directory_name(series_path.name)
|
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]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
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 database-first optimization"""
|
"""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")
|
_log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk (mode: {scan_mode})")
|
||||||
episode_dates = {}
|
episode_dates = {}
|
||||||
episodes_needing_lookup = []
|
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)
|
# 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")
|
_log("DEBUG", f"TIER 1 - Checking database for existing episode dates for {len(disk_episodes)} episodes")
|
||||||
db_cache_hits = 0
|
db_cache_hits = 0
|
||||||
@@ -264,7 +270,7 @@ class TVProcessor:
|
|||||||
|
|
||||||
for (season, episode) in episodes_needing_nfo_check:
|
for (season, episode) in episodes_needing_nfo_check:
|
||||||
# Look for existing NFO files for this episode
|
# Look for existing NFO files for this episode
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
season_dir = series_path / config.get_season_dir_name(season)
|
||||||
episode_files = disk_episodes[(season, episode)]
|
episode_files = disk_episodes[(season, episode)]
|
||||||
|
|
||||||
nfo_found = False
|
nfo_found = False
|
||||||
@@ -324,6 +330,12 @@ class TVProcessor:
|
|||||||
sonarr_data = sonarr_episodes[(season, episode)]
|
sonarr_data = sonarr_episodes[(season, episode)]
|
||||||
aired = sonarr_data.get('airDate')
|
aired = sonarr_data.get('airDate')
|
||||||
dateadded = sonarr_data.get('dateAdded')
|
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:
|
if dateadded:
|
||||||
source = "sonarr:history.import"
|
source = "sonarr:history.import"
|
||||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||||
@@ -372,6 +384,131 @@ class TVProcessor:
|
|||||||
|
|
||||||
return episode_dates
|
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: Check NFO files first for missing dateadded elements"""
|
||||||
|
_log("INFO", f"🔍 NFO-FIRST MODE: Checking {len(disk_episodes)} episodes for missing dateadded in NFO files")
|
||||||
|
episode_dates = {}
|
||||||
|
episodes_needing_db_check = []
|
||||||
|
episodes_needing_api_lookup = []
|
||||||
|
|
||||||
|
# STEP 1: Check each NFO file to determine if dateadded is missing
|
||||||
|
_log("DEBUG", f"STEP 1 - Checking NFO files for missing dateadded elements")
|
||||||
|
nfo_complete_count = 0
|
||||||
|
|
||||||
|
for (season, episode) in disk_episodes:
|
||||||
|
season_dir = series_path / config.get_season_dir_name(season)
|
||||||
|
episode_files = disk_episodes[(season, episode)]
|
||||||
|
|
||||||
|
nfo_has_dateadded = False
|
||||||
|
nfo_data = None
|
||||||
|
|
||||||
|
# Look for NFO file for this episode
|
||||||
|
for episode_file in episode_files:
|
||||||
|
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 and nfo_data.get('dateadded'):
|
||||||
|
# NFO has dateadded - this episode is complete
|
||||||
|
aired = nfo_data.get('aired')
|
||||||
|
dateadded = nfo_data.get('dateadded')
|
||||||
|
source = nfo_data.get('source', 'nfo_cache')
|
||||||
|
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||||
|
nfo_complete_count += 1
|
||||||
|
nfo_has_dateadded = True
|
||||||
|
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has dateadded={dateadded}, marked as complete")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not nfo_has_dateadded:
|
||||||
|
# NFO missing or missing dateadded - needs further processing
|
||||||
|
if nfo_data:
|
||||||
|
# NFO exists but missing dateadded
|
||||||
|
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO exists but missing dateadded - needs DB/API lookup")
|
||||||
|
else:
|
||||||
|
# No NFO file found
|
||||||
|
_log("DEBUG", f"S{season:02d}E{episode:02d}: No NFO file found - needs DB/API lookup")
|
||||||
|
episodes_needing_db_check.append((season, episode))
|
||||||
|
|
||||||
|
_log("INFO", f"NFO files with dateadded: {nfo_complete_count}/{len(disk_episodes)} episodes. Need DB check: {len(episodes_needing_db_check)}")
|
||||||
|
|
||||||
|
# STEP 2: For episodes missing dateadded in NFO, check database
|
||||||
|
if episodes_needing_db_check:
|
||||||
|
_log("DEBUG", f"STEP 2 - Checking database for {len(episodes_needing_db_check)} episodes missing dateadded")
|
||||||
|
db_hits = 0
|
||||||
|
|
||||||
|
for (season, episode) in episodes_needing_db_check:
|
||||||
|
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 and will add to NFO later
|
||||||
|
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} - will add to NFO")
|
||||||
|
else:
|
||||||
|
# Not in database or incomplete - needs API lookup
|
||||||
|
episodes_needing_api_lookup.append((season, episode))
|
||||||
|
|
||||||
|
_log("INFO", f"Database hits: {db_hits}/{len(episodes_needing_db_check)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
|
||||||
|
|
||||||
|
# STEP 3: For episodes still missing dateadded, query Sonarr
|
||||||
|
if episodes_needing_api_lookup:
|
||||||
|
_log("DEBUG", f"STEP 3 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from NFO and 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"🔍 NFO-FIRST 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]]:
|
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"""
|
"""Get episode information from Sonarr including import history - optimized to only fetch needed episodes"""
|
||||||
try:
|
try:
|
||||||
@@ -439,6 +576,11 @@ class TVProcessor:
|
|||||||
'dateAdded': None
|
'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)
|
# First try to get import date from history (more accurate)
|
||||||
episode_id = episode.get('id')
|
episode_id = episode.get('id')
|
||||||
if episode_id and episode.get('hasFile'):
|
if episode_id and episode.get('hasFile'):
|
||||||
@@ -645,7 +787,7 @@ class TVProcessor:
|
|||||||
|
|
||||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||||
if (season, episode) in disk_episodes:
|
if (season, episode) in disk_episodes:
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
season_dir = series_path / config.get_season_dir_name(season)
|
||||||
|
|
||||||
# Prepare NFO creation data
|
# Prepare NFO creation data
|
||||||
if config.manage_nfo:
|
if config.manage_nfo:
|
||||||
@@ -778,7 +920,7 @@ class TVProcessor:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if episode file exists on disk
|
# Check if episode file exists on disk
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
season_dir = series_path / config.get_season_dir_name(season_num)
|
||||||
if not season_dir.exists():
|
if not season_dir.exists():
|
||||||
_log("WARNING", f"Season directory not found: {season_dir}")
|
_log("WARNING", f"Season directory not found: {season_dir}")
|
||||||
continue
|
continue
|
||||||
@@ -831,7 +973,7 @@ class TVProcessor:
|
|||||||
# for webhook_episode in webhook_episodes:
|
# for webhook_episode in webhook_episodes:
|
||||||
# season_num = webhook_episode.get("seasonNumber")
|
# season_num = webhook_episode.get("seasonNumber")
|
||||||
# if season_num and season_num not in seasons_processed:
|
# if season_num and season_num not in seasons_processed:
|
||||||
# season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
# season_dir = series_path / config.get_season_dir_name(season_num)
|
||||||
# if season_dir.exists():
|
# if season_dir.exists():
|
||||||
# self.nfo_manager.create_season_nfo(season_dir, season_num)
|
# self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||||
# seasons_processed.add(season_num)
|
# seasons_processed.add(season_num)
|
||||||
@@ -1073,7 +1215,7 @@ class TVProcessor:
|
|||||||
# Create NFO if needed
|
# Create NFO if needed
|
||||||
nfo_success = True
|
nfo_success = True
|
||||||
if config.manage_nfo:
|
if config.manage_nfo:
|
||||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
season_dir = series_path / config.get_season_dir_name(season)
|
||||||
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
|
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
|
||||||
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
|
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ aiofiles==23.2.1
|
|||||||
aiohttp==3.8.6
|
aiohttp==3.8.6
|
||||||
psutil==5.9.6
|
psutil==5.9.6
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
APScheduler==3.10.4
|
||||||
|
croniter==1.4.1
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Scheduler module for NFOGuard
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -843,3 +843,60 @@ body {
|
|||||||
.d-flex { display: flex; }
|
.d-flex { display: flex; }
|
||||||
.justify-content-between { justify-content: space-between; }
|
.justify-content-between { justify-content: space-between; }
|
||||||
.align-items-center { align-items: center; }
|
.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;
|
||||||
|
}
|
||||||
+47
-2
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NFOGuard - Database Management</title>
|
<title>NFOGuard - Database Management</title>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css">
|
<link rel="stylesheet" href="/static/css/styles.css?v=manual-scan-ui">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -327,6 +327,51 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="tool-card">
|
||||||
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
<h3><i class="fas fa-database"></i> Database Statistics</h3>
|
||||||
<p>View detailed database information</p>
|
<p>View detailed database information</p>
|
||||||
@@ -404,6 +449,6 @@
|
|||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js?v=manual-scan-ui"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+281
-1
@@ -76,6 +76,7 @@ function initializeEventListeners() {
|
|||||||
// Forms
|
// Forms
|
||||||
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
|
document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
|
||||||
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
|
document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
|
||||||
|
document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API calls
|
// API calls
|
||||||
@@ -503,10 +504,21 @@ function showEpisodesModal(data) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th width="40px">
|
||||||
|
<input type="checkbox" id="select-all-checkbox" onchange="toggleSelectAll()">
|
||||||
|
</th>
|
||||||
<th>Episode</th>
|
<th>Episode</th>
|
||||||
<th>Aired</th>
|
<th>Aired</th>
|
||||||
<th>Date Added</th>
|
<th>Date Added</th>
|
||||||
@@ -529,7 +541,10 @@ function showEpisodesModal(data) {
|
|||||||
`<td>${dateadded}</td>`;
|
`<td>${dateadded}</td>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="${rowClass}" data-has-date="${!missingDate}">
|
<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>
|
||||||
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
|
<td>S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')}</td>
|
||||||
<td>${episode.aired || '-'}</td>
|
<td>${episode.aired || '-'}</td>
|
||||||
${dateCell}
|
${dateCell}
|
||||||
@@ -1436,6 +1451,169 @@ 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() {
|
async function logout() {
|
||||||
if (!confirm('Are you sure you want to logout?')) {
|
if (!confirm('Are you sure you want to logout?')) {
|
||||||
return;
|
return;
|
||||||
@@ -1462,3 +1640,105 @@ async function logout() {
|
|||||||
showToast('❌ Logout error', 'error');
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+68
-8
@@ -10,6 +10,49 @@ from datetime import datetime, timezone
|
|||||||
from zoneinfo import ZoneInfo
|
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):
|
class TimezoneAwareFormatter(logging.Formatter):
|
||||||
"""Formatter that respects the container timezone"""
|
"""Formatter that respects the container timezone"""
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -50,15 +93,32 @@ def _setup_file_logging():
|
|||||||
logger = logging.getLogger("NFOGuard")
|
logger = logging.getLogger("NFOGuard")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
file_handler = logging.handlers.RotatingFileHandler(
|
# Clear any existing handlers to avoid duplicates
|
||||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
logger.handlers.clear()
|
||||||
)
|
|
||||||
|
try:
|
||||||
|
file_handler = SafeRotatingFileHandler(
|
||||||
|
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = TimezoneAwareFormatter(
|
||||||
|
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
except Exception as e:
|
||||||
|
# If RotatingFileHandler fails, fall back to regular FileHandler
|
||||||
|
print(f"Warning: Could not setup rotating file handler ({e}), using regular file handler")
|
||||||
|
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)
|
||||||
|
except Exception as e2:
|
||||||
|
print(f"Error: Could not setup any file logging: {e2}")
|
||||||
|
|
||||||
formatter = TimezoneAwareFormatter(
|
|
||||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
|
||||||
)
|
|
||||||
file_handler.setFormatter(formatter)
|
|
||||||
logger.addHandler(file_handler)
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user