Compare commits

..

21 Commits

Author SHA1 Message Date
sbcrumb e883d896b9 Merge pull request 'dev' (#66) from dev into main
Local Docker Build (Main) / build (push) Successful in 18s
Local Docker Build (Main) / deploy (push) Successful in 0s
Reviewed-on: #66
2025-11-02 12:41:58 -05:00
sbcrumb 041caf0d0d update to deletes
Local Docker Build (Dev) / build-dev (push) Successful in 6s
Local Docker Build (Main) / build (pull_request) Successful in 7s
Local Docker Build (Main) / deploy (pull_request) Has been skipped
2025-10-29 13:33:45 -04:00
sbcrumb 7fffaac247 bulk delete
Local Docker Build (Dev) / build-dev (push) Successful in 18s
2025-10-29 10:06:35 -04:00
sbcrumb 6e491fb091 update: tasks
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-29 09:13:18 -04:00
sbcrumb 4b71b0e7ee update: remove autofix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-29 08:52:31 -04:00
sbcrumb 7bab45a9aa autfix update
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:09:55 -04:00
sbcrumb 7f0ad27009 update autfix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:07:08 -04:00
sbcrumb 614dab632a update fix .nfo
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:04:04 -04:00
sbcrumb 43449ca7a3 emby lookup fix
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 20:00:28 -04:00
sbcrumb db0a43dd18 db lookup routes
Local Docker Build (Dev) / build-dev (push) Successful in 5s
2025-10-28 19:55:10 -04:00
sbcrumb 6711e926a6 update : tasks sch
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-10-28 18:26:28 -04:00
sbcrumb f11839e005 feat: add cron for scans
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:33:08 -04:00
sbcrumb 6d2435a037 update scan
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:21:29 -04:00
sbcrumb b1598114d9 update: scan to check .nfo files first
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:16:15 -04:00
sbcrumb b709a377c8 update: to handle specials
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 17:00:56 -04:00
sbcrumb babe5ac0df udpates to detect specials
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-10-28 16:51:21 -04:00
sbcrumb a4c07eaa19 update: fix scans
Local Docker Build (Dev) / build-dev (push) Successful in 24s
2025-10-28 16:27:52 -04:00
sbcrumb a1a04b2711 web: scan updates
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 15:38:27 -04:00
sbcrumb 1dbad2f50b fix: manual scans
Local Docker Build (Dev) / build-dev (push) Successful in 6s
2025-10-28 15:29:11 -04:00
sbcrumb afdbe1f755 web: update web interface
Local Docker Build (Dev) / build-dev (push) Successful in 8s
2025-10-28 15:07:03 -04:00
sbcrumb 1916dc2646 nfo: updates to matching
Local Docker Build (Dev) / build-dev (push) Successful in 7s
2025-10-28 14:56:22 -04:00
19 changed files with 2985 additions and 1527 deletions
+63 -1
View File
@@ -119,4 +119,66 @@ class EpisodeResponse(BaseModel):
last_updated: str last_updated: str
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
View File
@@ -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
View File
@@ -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
@@ -1897,4 +1897,326 @@ def register_database_admin_routes(app, dependencies):
"query": "SELECT imdb_id, season, episode, dateadded, source FROM episodes WHERE has_video_file = FALSE ORDER BY imdb_id, season, episode" "query": "SELECT imdb_id, season, episode, dateadded, source FROM episodes WHERE has_video_file = FALSE ORDER BY imdb_id, season, episode"
} }
] ]
}
# 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)
} }
+6
View File
@@ -172,6 +172,12 @@ class NFOGuardConfig:
self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}") self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
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"""
+54 -4
View File
@@ -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 = {}
+267
View File
@@ -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
View File
@@ -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"
+28 -467
View File
@@ -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
View File
@@ -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 &lt;dateadded&gt; 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()">&times;</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()">&times;</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>
File diff suppressed because it is too large Load Diff
+144 -3
View File
@@ -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
View File
@@ -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
) )
+2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Scheduler module for NFOGuard
+396
View File
@@ -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
+58 -1
View File
@@ -842,4 +842,61 @@ body {
.d-block { display: block; } .d-block { display: block; }
.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
View File
@@ -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>
+282 -2
View File
@@ -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;
@@ -1461,4 +1639,106 @@ async function logout() {
console.error('Logout failed:', error); console.error('Logout failed:', error);
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
View File
@@ -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