diff --git a/api/models.py b/api/models.py
index d604b55..e497c3b 100644
--- a/api/models.py
+++ b/api/models.py
@@ -119,4 +119,66 @@ class EpisodeResponse(BaseModel):
last_updated: str
series_path: str
season_name: str
- episode_name: str
\ No newline at end of file
+ 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]
\ No newline at end of file
diff --git a/api/routes.py b/api/routes.py
index 676f3ec..cd3e519 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -15,6 +15,8 @@ from api.models import (
SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
)
+# Import logging utility
+from utils.logging import _log
# Web routes removed - handled by separate web container
# Global scan status tracking for detailed progress
@@ -50,7 +52,7 @@ async def _read_payload(request: Request) -> dict:
return json.loads(form["payload"])
return dict(form)
except Exception as e:
- print(f"ERROR: Failed to read webhook payload: {e}") # Using print since _log is not available
+ _log("ERROR", f"Failed to read webhook payload: {e}")
return {}
@@ -70,7 +72,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
raise HTTPException(status_code=422, detail="Empty Sonarr 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"]:
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", "")
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"}
# Find series path
@@ -97,7 +99,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de
# Extract episode data for targeted processing
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
# 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")
# 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:
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
if episodes_data and len(episodes_data) <= 3: # Single episode or small batch
processing_mode = "targeted"
- print(f"INFO: Forcing targeted mode for {len(episodes_data)} episode(s)")
+ _log("INFO", f"Forcing targeted mode for {len(episodes_data)} episode(s)")
# Add to batch queue with TV-prefixed key to avoid movie conflicts
tv_batch_key = f"tv:{imdb_id}"
@@ -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}"}
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}")
@@ -268,8 +270,8 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
try:
payload = await _read_payload(request)
- print(f"INFO: Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
- print(f"DEBUG: Full Radarr webhook payload: {payload}")
+ _log("INFO", f"Received Radarr webhook: {payload.get('eventType', 'Unknown')}")
+ _log("DEBUG", f"Full Radarr webhook payload: {payload}")
# Filter supported event types (same as Sonarr: Download, Upgrade, Rename)
event_type = payload.get('eventType', '')
@@ -279,29 +281,29 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de
# Extract movie info
movie_data = payload.get("movie", {})
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"}
# Get IMDb ID for batching key
imdb_id = movie_data.get("imdbId", "").lower()
if not imdb_id:
- print("WARNING: No IMDb ID in Radarr webhook movie data")
+ _log("WARNING", "No IMDb ID in Radarr webhook movie data")
return {"status": "error", "message": "No IMDb ID"}
# Get movie path and map it
movie_path = movie_data.get("folderPath") or movie_data.get("path", "")
if not movie_path:
- print("ERROR: No movie path in Radarr webhook")
+ _log("ERROR", "No movie path in Radarr webhook")
return {"status": "error", "message": "No movie path provided"}
# Map the path to container path
container_path = path_mapper.radarr_path_to_container_path(movie_path)
- print(f"DEBUG: Mapped Radarr path {movie_path} -> {container_path}")
+ _log("DEBUG", f"Mapped Radarr path {movie_path} -> {container_path}")
# CRITICAL: Verify the mapped path actually exists
if not Path(container_path).exists():
- print(f"ERROR: RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
- print(f"ERROR: This prevents processing wrong movies due to path mapping issues")
+ _log("ERROR", f"RADARR WEBHOOK REJECTED: Mapped path does not exist: {container_path}")
+ _log("ERROR", "This prevents processing wrong movies due to path mapping issues")
return {"status": "error", "message": f"Mapped movie path does not exist: {container_path}"}
# 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
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")
return {"status": "success", "message": f"Radarr webhook queued for {movie_batch_key}"}
except Exception as e:
- print(f"ERROR: Radarr webhook error: {e}")
+ _log("ERROR", f"Radarr webhook error: {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")
webhook = MaintainarrWebhook(**payload)
- print(f"INFO: Received Maintainarr webhook: {webhook.notification_type}")
- print(f"DEBUG: Full Maintainarr webhook payload: {payload}")
+ _log("INFO", f"Received Maintainarr webhook: {webhook.notification_type}")
+ _log("DEBUG", f"Full Maintainarr webhook payload: {payload}")
# Handle test notifications differently for debugging
notification_type = webhook.notification_type or ""
@@ -385,7 +387,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
break
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"}
# Try to extract title from subject or message
@@ -414,7 +416,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
elif movie:
media_type = "Movie"
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"}
# Process deletion based on media type
@@ -422,7 +424,7 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
removed_items = []
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
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):
removed_count += 1
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:
- 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:
- 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":
- 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
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):
removed_count += 1
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:
- 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:
- 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
if removed_count > 0:
@@ -481,9 +483,9 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask
}
except Exception as e:
- print(f"ERROR: Maintainarr webhook error: {e}")
+ _log("ERROR", f"Maintainarr webhook error: {e}")
import traceback
- print(f"ERROR: Traceback: {traceback.format_exc()}")
+ _log("ERROR", f"Traceback: {traceback.format_exc()}")
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". 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
@@ -786,9 +788,27 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
movie_skipped = 0
movie_processed = 0
+ def translate_host_path_to_container(path_str):
+ """Translate host paths to container paths for Docker volume mounts"""
+ path_str = str(path_str)
+ # Handle the common Docker volume mount: /mnt/unionfs/Media/ -> /media/
+ if path_str.startswith('/mnt/unionfs/Media/'):
+ container_path = path_str.replace('/mnt/unionfs/Media/', '/media/')
+ print(f"DEBUG: Translated host path '{path_str}' to container path '{container_path}'")
+ return container_path
+ # Handle case where user enters /Media/ instead of /media/ (case sensitivity)
+ elif path_str.startswith('/Media/'):
+ container_path = path_str.replace('/Media/', '/media/')
+ print(f"DEBUG: Fixed case sensitivity '{path_str}' to container path '{container_path}'")
+ return container_path
+ return path_str
+
paths_to_scan = []
if path:
- 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:
if scan_type in ["both", "tv"]:
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)
for scan_path in paths_to_scan:
+ print(f"DEBUG: Checking scan_path: {scan_path}, exists: {scan_path.exists()}")
if not scan_path.exists():
+ print(f"DEBUG: Path does not exist, skipping: {scan_path}")
continue
+ print(f"DEBUG: scan_type={scan_type}, path={path}, scan_path in tv_paths: {scan_path in config.tv_paths}")
if scan_type in ["both", "tv"] and (scan_path in config.tv_paths or path):
# Handle specific season/episode path
+ print(f"DEBUG: Entered TV processing branch")
if path and scan_path.name.lower().startswith('season'):
+ print(f"DEBUG: Taking season processing path")
# Single season processing
series_path = scan_path.parent
tv_processor_obj = dependencies.get("tv_processor")
@@ -814,6 +839,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
except Exception as e:
print(f"ERROR: Failed processing season {scan_path}: {e}")
elif path and scan_path.is_file() and scan_path.suffix.lower() in ('.mkv', '.mp4', '.avi'):
+ print(f"DEBUG: Taking single episode processing path")
# Single episode processing
season_path = scan_path.parent
series_path = season_path.parent
@@ -827,15 +853,19 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
except Exception as e:
print(f"ERROR: Failed processing episode {scan_path}: {e}")
else:
+ print(f"DEBUG: Taking series processing path")
# Check if this path itself is a series (has IMDb ID in directory name or NFO files)
tv_processor_obj = dependencies.get("tv_processor")
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
shutdown_event = dependencies.get("shutdown_event")
- 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:
# Determine force_scan based on scan mode
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
if result == "skipped":
tv_series_skipped += 1
@@ -900,7 +930,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
try:
# Determine force_scan based on scan mode
force_scan = (scan_mode == "full")
- result = tv_processor.process_series(item, force_scan=force_scan)
+ result = tv_processor.process_series(item, force_scan=force_scan, scan_mode=scan_mode)
tv_series_total += 1
if result == "skipped":
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
force_scan = (scan_mode == "full")
shutdown_event = dependencies.get("shutdown_event")
- result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
+ result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, scan_mode=scan_mode, shutdown_event=shutdown_event)
movie_total += 1
if result == "skipped":
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}"}
# 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():
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)}"}
+# ---------------------------
+# 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):
"""
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):
return await debug_tmdb_lookup(imdb_id, dependencies)
+ # ---------------------------
+ # Emby Plugin Lookup Endpoints
+ # ---------------------------
+
+ @app.get("/api/lookup/episode/{imdb_id}/{season}/{episode}")
+ async def _lookup_episode(imdb_id: str, season: int, episode: int):
+ return await lookup_episode(imdb_id, season, episode, dependencies)
+
+ @app.get("/api/lookup/movie/{imdb_id}")
+ async def _lookup_movie(imdb_id: str):
+ return await lookup_movie(imdb_id, dependencies)
+
+ @app.get("/api/lookup/movie/title/{title}")
+ async def _lookup_movie_by_title(title: str, year: str = None):
+ return await lookup_movie_by_title(title, year, dependencies)
+
+ @app.post("/api/lookup/movie/path")
+ async def _lookup_movie_by_path(request: Request):
+ data = await request.json()
+ nfo_path = data.get("nfo_path")
+ if not nfo_path:
+ raise HTTPException(status_code=400, detail="nfo_path required")
+ return await lookup_movie_comprehensive(nfo_path, dependencies)
+
+ @app.get("/api/debug/movie/{title}")
+ async def _debug_movie_lookup(title: str):
+ """Debug endpoint to check what movies exist for a given title"""
+ db = dependencies.get("db")
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT imdb_id, title, dateadded, source FROM movies WHERE LOWER(title) LIKE LOWER(%s)", (f"%{title}%",))
+ movies = cursor.fetchall()
+ return {"title_search": title, "matches": [dict(row) for row in movies]}
+
# Include monitoring routes
from api.monitoring_routes import router as monitoring_router
app.include_router(monitoring_router)
diff --git a/api/web_routes.py b/api/web_routes.py
index c6ddc12..faa86fc 100644
--- a/api/web_routes.py
+++ b/api/web_routes.py
@@ -1425,8 +1425,8 @@ def register_web_routes(app, dependencies):
# Create request with timeout (no body needed for query parameters)
req = urllib.request.Request(core_url, method='POST')
- # Make request with timeout
- with urllib.request.urlopen(req, timeout=30) as response:
+ # Make request with timeout (increased for manual scans which can take several minutes)
+ with urllib.request.urlopen(req, timeout=300) as response:
response_data = response.read().decode('utf-8')
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:
try:
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():
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"
}
]
+ }
+
+ # 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)
}
\ No newline at end of file
diff --git a/config/settings.py b/config/settings.py
index 5ef8290..ef45414 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -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_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
+
+ def get_season_dir_name(self, season: int) -> str:
+ """Get the directory name for a specific season, handling Season 0 as 'Specials'"""
+ if season == 0:
+ return "Specials"
+ return self.tv_season_dir_format.format(season=season)
def _load_auth_settings(self) -> None:
"""Load web interface authentication settings"""
diff --git a/core/async_nfo_manager.py b/core/async_nfo_manager.py
index 33fb337..d0ab52f 100644
--- a/core/async_nfo_manager.py
+++ b/core/async_nfo_manager.py
@@ -15,7 +15,8 @@ from utils.async_file_utils import (
async_file_exists,
async_set_file_mtime,
async_batch_nfo_operations,
- async_concurrent_episode_processing
+ async_concurrent_episode_processing,
+ aiofiles
)
from utils.nfo_patterns import (
create_basic_nfo_structure,
@@ -24,6 +25,7 @@ from utils.nfo_patterns import (
extract_imdb_id_from_text
)
from utils.validation import validate_date_string
+from utils.file_utils import VIDEO_EXTENSIONS
class AsyncNFOManager:
@@ -33,6 +35,54 @@ class AsyncNFOManager:
self.manager_brand = manager_brand
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 extract IMDb ID from directory path or filename
@@ -184,7 +234,7 @@ class AsyncNFOManager:
lock_metadata: bool = True
) -> bool:
"""
- Async create episode NFO file
+ Async create episode NFO file with proper video filename matching
Args:
season_dir: Path to season directory
@@ -199,8 +249,8 @@ class AsyncNFOManager:
True if successful, False otherwise
"""
try:
- nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
- nfo_path = season_dir / nfo_filename
+ # Use proper video filename matching to prevent NFO concatenation
+ nfo_path = await self._async_get_target_nfo_path(season_dir, season, episode)
# Prepare dates
dates = {}
diff --git a/core/database.py b/core/database.py
index de17faf..932f06b 100644
--- a/core/database.py
+++ b/core/database.py
@@ -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
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)")
@@ -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_resolved ON missing_imdb(resolved)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_path ON missing_imdb(file_path)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_enabled ON scheduled_scans(enabled)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_next_run ON scheduled_scans(next_run_at)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_schedule ON schedule_executions(schedule_id)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_status ON schedule_executions(status)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_started ON schedule_executions(started_at)")
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
"""Insert or update series record"""
with self.get_connection() as conn:
@@ -673,6 +720,226 @@ class NFOGuardDatabase:
return deleted_count > 0
+ # Scheduled Scans Methods
+
+ def create_scheduled_scan(self, name: str, description: str, cron_expression: str,
+ media_type: str, scan_mode: str, specific_paths: str = None,
+ enabled: bool = True, created_by: str = None) -> int:
+ """Create a new scheduled scan"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ INSERT INTO scheduled_scans
+ (name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id
+ """, (name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by))
+
+ return cursor.fetchone()['id']
+
+ def get_scheduled_scans(self, enabled_only: bool = False) -> List[Dict]:
+ """Get all scheduled scans"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ query = "SELECT * FROM scheduled_scans"
+ if enabled_only:
+ query += " WHERE enabled = TRUE"
+ query += " ORDER BY name"
+
+ cursor.execute(query)
+ return cursor.fetchall()
+
+ def get_scheduled_scan(self, scan_id: int) -> Optional[Dict]:
+ """Get a specific scheduled scan by ID"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("SELECT * FROM scheduled_scans WHERE id = %s", (scan_id,))
+ return cursor.fetchone()
+
+ def update_scheduled_scan(self, scan_id: int, name: str = None, description: str = None,
+ cron_expression: str = None, media_type: str = None,
+ scan_mode: str = None, specific_paths: str = None,
+ enabled: bool = None, updated_by: str = None) -> bool:
+ """Update a scheduled scan"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ updates = []
+ params = []
+
+ if name is not None:
+ updates.append("name = %s")
+ params.append(name)
+ if description is not None:
+ updates.append("description = %s")
+ params.append(description)
+ if cron_expression is not None:
+ updates.append("cron_expression = %s")
+ params.append(cron_expression)
+ if media_type is not None:
+ updates.append("media_type = %s")
+ params.append(media_type)
+ if scan_mode is not None:
+ updates.append("scan_mode = %s")
+ params.append(scan_mode)
+ if specific_paths is not None:
+ updates.append("specific_paths = %s")
+ params.append(specific_paths)
+ if enabled is not None:
+ updates.append("enabled = %s")
+ params.append(enabled)
+ if updated_by is not None:
+ updates.append("updated_by = %s")
+ params.append(updated_by)
+
+ updates.append("updated_at = CURRENT_TIMESTAMP")
+ params.append(scan_id)
+
+ if not updates:
+ return False
+
+ query = f"UPDATE scheduled_scans SET {', '.join(updates)} WHERE id = %s"
+ cursor.execute(query, params)
+
+ return cursor.rowcount > 0
+
+ def delete_scheduled_scan(self, scan_id: int) -> bool:
+ """Delete a scheduled scan and its execution history"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("DELETE FROM scheduled_scans WHERE id = %s", (scan_id,))
+ return cursor.rowcount > 0
+
+ def update_scan_next_run(self, scan_id: int, next_run_at: datetime) -> bool:
+ """Update the next run time for a scheduled scan"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ UPDATE scheduled_scans
+ SET next_run_at = %s, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """, (next_run_at, scan_id))
+
+ return cursor.rowcount > 0
+
+ def update_scan_last_run(self, scan_id: int, last_run_at: datetime = None) -> bool:
+ """Update the last run time and increment run count for a scheduled scan"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ if last_run_at is None:
+ last_run_at = datetime.utcnow()
+
+ cursor.execute("""
+ UPDATE scheduled_scans
+ SET last_run_at = %s, run_count = run_count + 1, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """, (last_run_at, scan_id))
+
+ return cursor.rowcount > 0
+
+ # Schedule Execution Methods
+
+ def create_schedule_execution(self, schedule_id: int, media_type: str, scan_mode: str,
+ triggered_by: str = None) -> int:
+ """Create a new schedule execution record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ INSERT INTO schedule_executions
+ (schedule_id, status, media_type, scan_mode, triggered_by)
+ VALUES (%s, 'running', %s, %s, %s)
+ RETURNING id
+ """, (schedule_id, media_type, scan_mode, triggered_by))
+
+ return cursor.fetchone()['id']
+
+ def update_schedule_execution(self, execution_id: int, status: str = None,
+ items_processed: int = None, items_skipped: int = None,
+ items_failed: int = None, error_message: str = None,
+ logs: str = None) -> bool:
+ """Update a schedule execution record"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ updates = []
+ params = []
+
+ if status is not None:
+ updates.append("status = %s")
+ params.append(status)
+ if status in ['completed', 'failed', 'cancelled']:
+ updates.append("completed_at = CURRENT_TIMESTAMP")
+ updates.append("execution_time_seconds = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at))")
+
+ if items_processed is not None:
+ updates.append("items_processed = %s")
+ params.append(items_processed)
+ if items_skipped is not None:
+ updates.append("items_skipped = %s")
+ params.append(items_skipped)
+ if items_failed is not None:
+ updates.append("items_failed = %s")
+ params.append(items_failed)
+ if error_message is not None:
+ updates.append("error_message = %s")
+ params.append(error_message)
+ if logs is not None:
+ updates.append("logs = %s")
+ params.append(logs)
+
+ if not updates:
+ return False
+
+ params.append(execution_id)
+ query = f"UPDATE schedule_executions SET {', '.join(updates)} WHERE id = %s"
+ cursor.execute(query, params)
+
+ return cursor.rowcount > 0
+
+ def get_schedule_executions(self, schedule_id: int = None, limit: int = 50) -> List[Dict]:
+ """Get schedule execution history"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ query = """
+ SELECT se.*, ss.name as schedule_name
+ FROM schedule_executions se
+ JOIN scheduled_scans ss ON se.schedule_id = ss.id
+ """
+ params = []
+
+ if schedule_id is not None:
+ query += " WHERE se.schedule_id = %s"
+ params.append(schedule_id)
+
+ query += " ORDER BY se.started_at DESC LIMIT %s"
+ params.append(limit)
+
+ cursor.execute(query, params)
+ return cursor.fetchall()
+
+ def get_running_executions(self) -> List[Dict]:
+ """Get currently running schedule executions"""
+ with self.get_connection() as conn:
+ cursor = conn.cursor()
+
+ cursor.execute("""
+ SELECT se.*, ss.name as schedule_name
+ FROM schedule_executions se
+ JOIN scheduled_scans ss ON se.schedule_id = ss.id
+ WHERE se.status = 'running'
+ ORDER BY se.started_at DESC
+ """)
+
+ return cursor.fetchall()
+
def close(self):
"""Close all database connections"""
if hasattr(self._local, 'connection'):
diff --git a/core/nfo_manager.py b/core/nfo_manager.py
index 9010b0c..473c771 100644
--- a/core/nfo_manager.py
+++ b/core/nfo_manager.py
@@ -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')}")
return result
- except (ET.ParseError, Exception) as e:
- print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
+ except ET.ParseError as 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
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'true', content, re.IGNORECASE):
+ return None
+
+ # Extract dateadded
+ dateadded_match = re.search(r'([^<]+)', 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'([^<]+)', content, re.IGNORECASE)
+ if premiered_match:
+ result["released"] = premiered_match.group(1).strip()
+
+ aired_match = re.search(r'([^<]+)', content, re.IGNORECASE)
+ if aired_match:
+ result["aired"] = aired_match.group(1).strip()
+
+ # Extract source from NFOGuard comment
+ source_match = re.search(r'', 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]]:
"""Extract NFOGuard-managed dates from existing episode NFO file"""
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css
index 678e734..ba017d1 100644
--- a/nfoguard-web/static/css/styles.css
+++ b/nfoguard-web/static/css/styles.css
@@ -49,32 +49,10 @@ body {
text-align: center;
}
-/* Header Logo and Text Layout */
-.header-logo {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 1rem;
-}
-
-.header-logo .logo {
- height: 60px;
- width: auto;
- /* Clean display for new logo */
- object-fit: contain;
-}
-
-.header-text {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- text-align: left;
-}
-
.header-content h1 {
font-size: 2rem;
font-weight: 300;
- margin-bottom: 0.25rem;
+ margin-bottom: 0.5rem;
}
.header-content h1 i {
@@ -84,28 +62,6 @@ body {
.header-content p {
opacity: 0.9;
font-size: 1rem;
- margin: 0;
-}
-
-/* Responsive logo layout */
-@media (max-width: 768px) {
- .header-logo {
- flex-direction: column;
- gap: 0.5rem;
- }
-
- .header-text {
- align-items: center;
- text-align: center;
- }
-
- .header-logo .logo {
- height: 45px;
- }
-
- .header-content h1 {
- font-size: 1.5rem;
- }
}
/* Authentication Status */
@@ -888,454 +844,59 @@ body {
.justify-content-between { justify-content: space-between; }
.align-items-center { align-items: center; }
-/* Database Admin Tools Styles */
-.query-results {
+/* Manual Scan Styles */
+.scan-status {
margin-top: 1rem;
padding: 1rem;
- background: #f8f9fa;
+ background-color: var(--light-color);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
}
-.query-info {
- padding: 1rem;
- border-radius: 0.375rem;
+.scan-progress {
margin-bottom: 1rem;
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 0.875rem;
}
-.query-info.success {
- background-color: #d4edda;
- border: 1px solid #c3e6cb;
- color: #155724;
-}
-
-.query-info.error {
- background-color: #f8d7da;
- border: 1px solid #f5c6cb;
- color: #721c24;
-}
-
-.results-table {
+.progress-bar {
width: 100%;
- border-collapse: collapse;
- font-size: 0.875rem;
- background: white;
+ height: 1.5rem;
+ background-color: #e9ecef;
border-radius: 0.375rem;
overflow: hidden;
- box-shadow: var(--shadow);
-}
-
-.results-table th {
- background-color: var(--dark-color);
- color: white;
- padding: 0.75rem;
- text-align: left;
- font-weight: 600;
-}
-
-.results-table td {
- padding: 0.75rem;
- border-bottom: 1px solid var(--border-color);
- vertical-align: top;
-}
-
-.results-table tr:hover {
- background-color: #f8f9fa;
-}
-
-.results-table td em {
- color: var(--text-muted);
- font-style: italic;
-}
-
-.query-results .table-container,
-.admin-results .table-container {
- max-height: 400px;
- overflow-y: auto;
- border: 1px solid var(--border-color);
- border-radius: 0.375rem;
-}
-
-/* NFO Repair Table Styling */
-.nfo-repair-table-container {
- background: white;
- border-radius: 0.5rem;
- box-shadow: var(--shadow);
- overflow-x: auto;
- overflow-y: auto;
- max-height: 600px;
- margin-bottom: 1rem;
- border: 1px solid var(--border-color);
-}
-
-.nfo-repair-table {
- width: 100%;
- min-width: 800px;
- border-collapse: collapse;
- font-size: 0.875rem;
- background: white;
-}
-
-.nfo-repair-table th {
- background-color: var(--dark-color);
- color: white;
- padding: 0.75rem 0.5rem;
- text-align: left;
- font-weight: 600;
- position: sticky;
- top: 0;
- z-index: 10;
-}
-
-.nfo-repair-table td {
- padding: 0.75rem 0.5rem;
- border-bottom: 1px solid var(--border-color);
- vertical-align: top;
-}
-
-.nfo-repair-table tr:hover {
- background-color: #f8f9fa;
-}
-
-.media-type-badge {
- padding: 0.25rem 0.5rem;
- border-radius: 0.25rem;
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
-}
-
-.media-type-badge.episode {
- background-color: #e3f2fd;
- color: #1976d2;
-}
-
-.media-type-badge.movie {
- background-color: #f3e5f5;
- color: #7b1fa2;
-}
-
-.status-ready {
- color: #2e7d32;
- font-weight: 600;
- font-size: 0.75rem;
-}
-
-.status-missing {
- color: #d32f2f;
- font-weight: 600;
- font-size: 0.75rem;
-}
-
-.text-center {
- text-align: center;
-}
-
-.text-muted {
- color: #6c757d;
-}
-
-.nfo-fix-actions {
- margin-top: 1rem;
- padding: 1rem;
- background-color: #f8f9fa;
- border-radius: 0.5rem;
- text-align: center;
-}
-
-.nfo-fix-actions .btn {
margin-bottom: 0.5rem;
}
-.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;
- flex-direction: column;
- gap: 0.5rem;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.875rem;
}
-.admin-tools .btn {
- text-align: left;
- justify-content: flex-start;
-}
-
-.admin-results {
- margin-top: 1rem;
- padding: 1rem;
- background: #f8f9fa;
- border: 1px solid var(--border-color);
- border-radius: 0.375rem;
-}
-
-.scan-summary,
-.fix-summary {
- padding: 1rem;
- border-radius: 0.375rem;
- margin-bottom: 1rem;
-}
-
-.scan-summary {
- background-color: #e7f3ff;
- border: 1px solid #b3d9ff;
-}
-
-.fix-summary.success {
- background-color: #d4edda;
- border: 1px solid #c3e6cb;
- color: #155724;
-}
-
-.fix-errors {
- background-color: #fff3cd;
- border: 1px solid #ffeaa7;
- padding: 1rem;
- border-radius: 0.375rem;
- margin-top: 1rem;
-}
-
-.fix-errors ul {
- margin: 0.5rem 0 0 1rem;
-}
-
-.missing-episodes {
- margin-top: 1rem;
-}
-
-.loading {
- text-align: center;
- padding: 2rem;
+.scan-info span:first-child {
color: var(--text-muted);
- font-style: italic;
}
-.loading::before {
- content: "⏳ ";
-}
-
-.success {
- background-color: #d4edda;
- border: 1px solid #c3e6cb;
- color: #155724;
- padding: 1rem;
- border-radius: 0.375rem;
- margin: 1rem 0;
-}
-
-.error {
- background-color: #f8d7da;
- border: 1px solid #f5c6cb;
- color: #721c24;
- padding: 1rem;
- border-radius: 0.375rem;
- margin: 1rem 0;
-}
-
-/* Make text areas more distinctive */
-textarea {
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 0.875rem;
- resize: vertical;
-}
-
-/* Button variations for admin tools */
-.btn.btn-info {
- background-color: #17a2b8;
- border-color: #17a2b8;
- color: white;
-}
-
-.btn.btn-info:hover {
- background-color: #138496;
- border-color: #117a8b;
-}
-
-/* Missing IMDb Items Styles */
-.missing-imdb-table-container {
- overflow-x: auto;
- max-height: 600px;
- border: 1px solid var(--border-color);
- border-radius: 8px;
- margin: 1rem 0;
-}
-
-.missing-imdb-table {
- width: 100%;
- border-collapse: collapse;
- min-width: 800px;
-}
-
-.missing-imdb-table thead {
- background-color: var(--bg-secondary);
- position: sticky;
- top: 0;
- z-index: 1;
-}
-
-.missing-imdb-table th,
-.missing-imdb-table td {
- padding: 0.75rem;
- text-align: left;
- border-bottom: 1px solid var(--border-color);
- vertical-align: top;
-}
-
-.missing-imdb-table th {
+.scan-info span:last-child {
font-weight: 600;
- color: var(--text-primary);
- white-space: nowrap;
-}
-
-.missing-imdb-table td {
- color: var(--text-secondary);
-}
-
-.missing-imdb-table .file-path {
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 0.85rem;
- max-width: 300px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.media-type-badge {
- display: inline-block;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
-}
-
-.media-type-badge.tv-series {
- background-color: #e3f2fd;
- color: #1976d2;
-}
-
-.media-type-badge.movie {
- background-color: #fff3e0;
- color: #f57c00;
-}
-
-.missing-imdb-actions {
- background-color: var(--bg-secondary);
- padding: 1rem;
- border-radius: 8px;
- margin-top: 1rem;
-}
-
-.missing-imdb-actions ul {
- margin: 0.5rem 0 0 1rem;
- padding: 0;
-}
-
-.missing-imdb-actions li {
- margin-bottom: 0.5rem;
-}
-
-.missing-imdb-actions code {
- background-color: var(--bg-primary);
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 0.85rem;
- border: 1px solid var(--border-color);
-}
-
-.btn-small {
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
- min-width: auto;
-}
-
-/* NFO Statistics Styling */
-.nfo-stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 1rem;
- margin: 1rem 0;
-}
-
-.stat-box {
- background-color: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 1rem;
- text-align: center;
-}
-
-.stat-number {
- font-size: 2rem;
- font-weight: 700;
color: var(--primary-color);
- margin-bottom: 0.5rem;
}
-.stat-label {
+.form-group small {
+ display: block;
+ margin-top: 0.25rem;
+ color: var(--text-muted);
font-size: 0.875rem;
- color: var(--text-secondary);
- font-weight: 500;
}
-.recommendation-box {
- background-color: #fff3cd;
- border: 1px solid #ffeaa7;
- border-radius: 8px;
- padding: 1rem;
- margin: 1rem 0;
-}
-
-.recommendation-box ul {
- margin: 0.5rem 0 0 1rem;
- padding: 0;
-}
-
-.recommendation-box li {
- margin-bottom: 0.5rem;
-}
-
-.success-box {
- background-color: #d4edda;
- border: 1px solid #c3e6cb;
- color: #155724;
- border-radius: 8px;
- padding: 1rem;
- margin: 1rem 0;
- text-align: center;
- font-weight: 600;
-}
-
-/* Responsive adjustments for admin tools */
-@media (max-width: 768px) {
- .table-container {
- max-height: 300px;
- }
-
- .results-table {
- font-size: 0.75rem;
- }
-
- .results-table th,
- .results-table td {
- padding: 0.5rem;
- }
-
- .admin-tools {
- gap: 0.75rem;
- }
-
- .missing-imdb-table {
- min-width: 600px;
- }
-
- .missing-imdb-table th,
- .missing-imdb-table td {
- padding: 0.5rem;
- font-size: 0.85rem;
- }
-
- .missing-imdb-table .file-path {
- max-width: 200px;
- }
+.btn-sm {
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
}
\ No newline at end of file
diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html
index 1f5a72a..d0bd51c 100644
--- a/nfoguard-web/static/index.html
+++ b/nfoguard-web/static/index.html
@@ -4,7 +4,7 @@
NFOGuard - Database Management
-
+
@@ -12,12 +12,8 @@