diff --git a/.env.example b/.env.example
index 49a2925..7ef2928 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,23 @@ SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
# Download detection paths (for identifying downloads vs existing files)
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
+# ===========================================
+# SERVER CONFIGURATION
+# ===========================================
+# Timezone for container logging and timestamps
+TZ=America/New_York
+
+# NFOGuard Core API (webhooks, processing, database management)
+CORE_API_HOST=0.0.0.0
+CORE_API_PORT=8080
+
+# NFOGuard Web Interface (dashboard, series/movie management)
+WEB_API_HOST=0.0.0.0
+WEB_API_PORT=8081
+
+# External port where web interface is accessible (for dynamic port reference)
+WEB_EXTERNAL_PORT=8081
+
# ===========================================
# DATABASE CONFIGURATION
# ===========================================
@@ -34,6 +51,7 @@ DB_TYPE=postgresql
DB_HOST=nfoguard-db # Container name from docker-compose
DB_PORT=5432
DB_NAME=nfoguard
+DB_USER=nfoguard
# Legacy SQLite Configuration (Deprecated in v2.6+)
# Only use for existing SQLite installations
@@ -142,10 +160,4 @@ SUPPRESS_TVDB_WARNINGS=true
WEB_AUTH_ENABLED=false
# Session timeout in seconds (default: 3600 = 1 hour)
-WEB_AUTH_SESSION_TIMEOUT=3600
-
-# ===========================================
-# SERVER CONFIGURATION
-# ===========================================
-# Port for webhook server
-PORT=8080
\ No newline at end of file
+WEB_AUTH_SESSION_TIMEOUT=3600
\ No newline at end of file
diff --git a/.gitea/workflows/ci-dev.yml b/.gitea/workflows/ci-dev.yml
index 5772075..dd78ebc 100644
--- a/.gitea/workflows/ci-dev.yml
+++ b/.gitea/workflows/ci-dev.yml
@@ -11,6 +11,10 @@ jobs:
runs-on: host
steps:
+ - name: Pre-cleanup
+ run: |
+ docker system prune -f
+ docker builder prune -f --filter until=24h
- name: Checkout code (DEV)
run: |
echo "Current workspace: $(pwd)"
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 0f428a2..a25a4a5 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -11,6 +11,15 @@ jobs:
runs-on: host
steps:
+ - name: Pre-cleanup Docker space
+ run: |
+ echo "๐งน Cleaning up Docker space before build..."
+ docker system prune -f
+ docker builder prune -f --filter until=24h
+ docker image prune -af --filter until=48h
+ echo "๐ Docker space after cleanup:"
+ docker system df
+
- name: Checkout code
run: |
echo "Current workspace: $(pwd)"
@@ -203,6 +212,15 @@ jobs:
echo ""
echo "๐ณ Pull with: docker pull 192.168.253.221:3000/sbcrumb/nfoguard:${VERSION}-gitea"
+ - name: Post-cleanup Docker space
+ if: always() # Run even if build fails
+ run: |
+ echo "๐งน Final cleanup to prevent disk space issues..."
+ docker system prune -f
+ docker builder prune -af --filter until=24h
+ echo "๐ Final Docker space usage:"
+ docker system df
+
deploy:
needs: build
runs-on: host
diff --git a/Dockerfile b/Dockerfile
index 342b7c4..08cc173 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -32,6 +32,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
# Copy application code
COPY . .
+# Copy web starter file to root directory
+COPY start_web.py .
+
# Create git metadata for version detection based on build arg
RUN mkdir -p .git && \
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
diff --git a/SETUP.md b/SETUP.md
index 0798ec5..e8e1d03 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -132,7 +132,7 @@ services:
- ./.env:/app/.env:ro # Main configuration
- ./.env.secrets:/app/.env.secrets:ro # Secrets
environment:
- - PORT=8080
+ - CORE_API_PORT=8080
depends_on:
- radarr-postgres
```
diff --git a/VERSION b/VERSION
index c959dfb..834f262 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.6.12
+2.8.0
diff --git a/api/auth.py b/api/auth.py
index 9688f0e..fedaba9 100644
--- a/api/auth.py
+++ b/api/auth.py
@@ -72,10 +72,10 @@ class AuthSession:
class SimpleAuthMiddleware(BaseHTTPMiddleware):
"""Simple authentication middleware for web interface routes"""
- def __init__(self, app, config):
+ def __init__(self, app, config, session_manager=None):
super().__init__(app)
self.config = config
- self.session_manager = AuthSession(config.web_auth_session_timeout)
+ self.session_manager = session_manager or AuthSession(config.web_auth_session_timeout)
self.security = HTTPBasic()
# Routes that require authentication (web interface)
@@ -92,6 +92,8 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware):
self.public_routes = [
"/webhook/",
"/health",
+ "/logo/", # Logo files should always be accessible
+ "/favicon.ico", # Favicon should always be accessible
"/ping",
"/api/v1/health",
"/api/v1/metrics",
diff --git a/api/routes.py b/api/routes.py
index 77e85d9..74b657e 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -15,11 +15,24 @@ from api.models import (
SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
)
-from api.web_routes import (
- get_movies_list, get_tv_series_list, get_series_episodes, get_series_sources, get_missing_dates_report,
- get_dashboard_stats, update_movie_date, update_episode_date, bulk_update_source,
- get_movie_date_options, get_episode_date_options, debug_series_date_distribution
-)
+# Web routes removed - handled by separate web container
+
+# Global scan status tracking for detailed progress
+scan_status = {
+ "scanning": False,
+ "scan_type": None,
+ "scan_mode": None,
+ "start_time": None,
+ "current_operation": None,
+ "tv_series_processed": 0,
+ "tv_series_total": 0,
+ "tv_series_skipped": 0,
+ "movies_processed": 0,
+ "movies_total": 0,
+ "movies_skipped": 0,
+ "current_item": None,
+ "last_update": None
+}
# ---------------------------
@@ -569,6 +582,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
print(f"๐ MANUAL SCAN STARTED: {scan_type} scan (mode: {scan_mode}) initiated at {local_start.strftime('%Y-%m-%d %H:%M:%S')}{tz_display}")
+ # Initialize scan tracking
+ start_scan_tracking(scan_type, scan_mode)
+
# Initialize counters for scan statistics
tv_series_total = 0
tv_series_skipped = 0
@@ -629,86 +645,110 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
else:
# Full series processing - scan subdirectories
import re
- tv_count = 0
+
+ # Count total series first for progress tracking
+ tv_series_list = []
for item in scan_path.iterdir():
+ if (item.is_dir() and
+ not item.name.lower().startswith('season') and
+ not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
+ nfo_manager.parse_imdb_from_path(item)):
+ tv_series_list.append(item)
+
+ tv_series_count = len(tv_series_list)
+ update_scan_status("tv", tv_series_total=tv_series_count)
+ print(f"INFO: Found {tv_series_count} TV series to process")
+
+ tv_count = 0
+ for item in tv_series_list:
# Check for shutdown signal at start of each item
shutdown_event = dependencies.get("shutdown_event")
if shutdown_event and shutdown_event.is_set():
print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
return
- if (item.is_dir() and
- not item.name.lower().startswith('season') and
- not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
- nfo_manager.parse_imdb_from_path(item)):
- tv_count += 1
- try:
- # Determine force_scan based on scan mode
- force_scan = (scan_mode == "full")
- result = tv_processor.process_series(item, force_scan=force_scan)
- tv_series_total += 1
- if result == "skipped":
- tv_series_skipped += 1
- elif result == "processed":
- tv_series_processed += 1
- except Exception as e:
- print(f"ERROR: Failed processing TV series {item}: {e}")
- tv_series_total += 1
+ tv_count += 1
+ update_scan_status(current_item=item.name, tv_series_processed=tv_count)
+
+ try:
+ # Determine force_scan based on scan mode
+ force_scan = (scan_mode == "full")
+ result = tv_processor.process_series(item, force_scan=force_scan)
+ tv_series_total += 1
+ if result == "skipped":
+ tv_series_skipped += 1
+ elif result == "processed":
+ tv_series_processed += 1
+ except Exception as e:
+ print(f"ERROR: Failed processing TV series {item}: {e}")
+ tv_series_total += 1
+
+ # Yield control every TV series to allow other requests
+ if tv_count % 1 == 0:
+ await asyncio.sleep(0.2) # 200ms yield to process other requests
+ print(f"INFO: Processed {tv_count} TV series, yielding to other requests...")
- # Yield control every TV series to allow other requests
- if tv_count % 1 == 0:
- await asyncio.sleep(0.2) # 200ms yield to process other requests
- print(f"INFO: Processed {tv_count} TV series, yielding to other requests...")
-
- # Check for shutdown signal
- shutdown_event = dependencies.get("shutdown_event")
- if shutdown_event and shutdown_event.is_set():
- print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
- return
+ # Check for shutdown signal
+ shutdown_event = dependencies.get("shutdown_event")
+ if shutdown_event and shutdown_event.is_set():
+ print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
+ return
if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
print(f"INFO: Scanning movies in: {scan_path}")
- movie_count = 0
+ update_scan_status("movies", current_item="Counting movies...")
+
+ # Count total movies first for progress tracking
+ movie_list = []
for item in scan_path.iterdir():
+ if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
+ movie_list.append(item)
+
+ movie_total_count = len(movie_list)
+ update_scan_status(movies_total=movie_total_count)
+ print(f"INFO: Found {movie_total_count} movies to process")
+
+ movie_count = 0
+ for item in movie_list:
# Check for shutdown signal at start of each movie
shutdown_event = dependencies.get("shutdown_event")
if shutdown_event and shutdown_event.is_set():
print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
return
- if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
- movie_count += 1
- print(f"INFO: Processing movie: {item.name}")
- try:
- # Determine force_scan based on scan mode
- force_scan = (scan_mode == "full")
- shutdown_event = dependencies.get("shutdown_event")
- result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
- movie_total += 1
- if result == "skipped":
- movie_skipped += 1
- elif result == "processed":
- movie_processed += 1
- elif result == "no_video_files":
- print(f"INFO: Skipped empty directory: {item.name}")
- movie_skipped += 1
- elif result == "shutdown":
- print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping movie scan gracefully")
- return
- except Exception as e:
- print(f"ERROR: Failed processing movie {item}: {e}")
- movie_total += 1
+ movie_count += 1
+ update_scan_status(current_item=item.name, movies_processed=movie_count)
+ print(f"INFO: Processing movie: {item.name}")
+ try:
+ # Determine force_scan based on scan mode
+ force_scan = (scan_mode == "full")
+ shutdown_event = dependencies.get("shutdown_event")
+ result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
+ movie_total += 1
+ if result == "skipped":
+ movie_skipped += 1
+ elif result == "processed":
+ movie_processed += 1
+ elif result == "no_video_files":
+ print(f"INFO: Skipped empty directory: {item.name}")
+ movie_skipped += 1
+ elif result == "shutdown":
+ print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping movie scan gracefully")
+ return
+ except Exception as e:
+ print(f"ERROR: Failed processing movie {item}: {e}")
+ movie_total += 1
+
+ # Yield control every 2 movies to allow other requests (webhooks, web interface)
+ if movie_count % 2 == 0:
+ await asyncio.sleep(0.2) # 200ms yield to process other requests
+ print(f"INFO: Processed {movie_count} movies, yielding to other requests...")
- # Yield control every 2 movies to allow other requests (webhooks, web interface)
- if movie_count % 2 == 0:
- await asyncio.sleep(0.2) # 200ms yield to process other requests
- print(f"INFO: Processed {movie_count} movies, yielding to other requests...")
-
- # Check for shutdown signal
- shutdown_event = dependencies.get("shutdown_event")
- if shutdown_event and shutdown_event.is_set():
- print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
- return
+ # Check for shutdown signal
+ shutdown_event = dependencies.get("shutdown_event")
+ if shutdown_event and shutdown_event.is_set():
+ print("INFO: โ ๏ธ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
+ return
print(f"INFO: Completed movie scan: {movie_count} movies processed in {scan_path}")
@@ -737,6 +777,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
print(f"โ
MANUAL SCAN COMPLETED: {scan_type} scan (mode: {scan_mode}) finished at {local_end.strftime('%Y-%m-%d %H:%M:%S')}{tz_display}")
print(f"โฑ๏ธ MANUAL SCAN DURATION: {duration_str} (total time: {duration.total_seconds():.1f} seconds)")
+ # Stop scan tracking
+ stop_scan_tracking()
+
# Print optimization statistics for TV scans
if scan_type in ["both", "tv"] and tv_series_total > 0:
print(f"๐ TV SCAN OPTIMIZATION: Total: {tv_series_total}, Processed: {tv_series_processed}, Skipped: {tv_series_skipped}")
@@ -1736,6 +1779,113 @@ async def backfill_movie_release_dates(dependencies: dict):
}
+# ---------------------------
+# Scan Status Functions
+# ---------------------------
+
+async def get_scan_status():
+ """Get detailed scan status with progress information"""
+ global scan_status
+
+ if not scan_status["scanning"]:
+ return {"scanning": False, "message": "No active scan"}
+
+ # Calculate elapsed time
+ from datetime import datetime
+ if scan_status["start_time"]:
+ elapsed_seconds = int((datetime.now() - scan_status["start_time"]).total_seconds())
+ if elapsed_seconds >= 60:
+ minutes = elapsed_seconds // 60
+ seconds = elapsed_seconds % 60
+ elapsed_str = f"{minutes}m {seconds}s"
+ else:
+ elapsed_str = f"{elapsed_seconds}s"
+ else:
+ elapsed_str = "unknown"
+
+ # Build detailed status message
+ if scan_status["current_operation"] == "tv":
+ if scan_status["tv_series_total"] > 0:
+ message = f"Processing TV series ({scan_status['tv_series_processed']}/{scan_status['tv_series_total']}) - {elapsed_str} elapsed"
+ else:
+ message = f"Processed {scan_status['tv_series_processed']} TV series - {elapsed_str} elapsed"
+ elif scan_status["current_operation"] == "movies":
+ if scan_status["movies_total"] > 0:
+ message = f"Processing movies ({scan_status['movies_processed']}/{scan_status['movies_total']}) - {elapsed_str} elapsed"
+ else:
+ message = f"Processed {scan_status['movies_processed']} movies - {elapsed_str} elapsed"
+ else:
+ message = f"Scan in progress - {elapsed_str} elapsed"
+
+ # Add current item if available
+ if scan_status["current_item"]:
+ message += f" | Current: {scan_status['current_item']}"
+
+ return {
+ "scanning": True,
+ "message": message,
+ "scan_type": scan_status["scan_type"],
+ "scan_mode": scan_status["scan_mode"],
+ "elapsed_seconds": elapsed_seconds if scan_status["start_time"] else 0,
+ "current_operation": scan_status["current_operation"],
+ "tv_series_processed": scan_status["tv_series_processed"],
+ "tv_series_total": scan_status["tv_series_total"],
+ "tv_series_skipped": scan_status["tv_series_skipped"],
+ "movies_processed": scan_status["movies_processed"],
+ "movies_total": scan_status["movies_total"],
+ "movies_skipped": scan_status["movies_skipped"],
+ "current_item": scan_status["current_item"]
+ }
+
+def update_scan_status(operation=None, current_item=None, **kwargs):
+ """Update scan status with new progress information"""
+ global scan_status
+
+ if operation:
+ scan_status["current_operation"] = operation
+ if current_item:
+ scan_status["current_item"] = current_item
+
+ # Update counters
+ for key, value in kwargs.items():
+ if key in scan_status:
+ scan_status[key] = value
+
+ scan_status["last_update"] = datetime.now()
+
+def start_scan_tracking(scan_type, scan_mode):
+ """Initialize scan tracking"""
+ global scan_status
+
+ scan_status.update({
+ "scanning": True,
+ "scan_type": scan_type,
+ "scan_mode": scan_mode,
+ "start_time": datetime.now(),
+ "current_operation": None,
+ "tv_series_processed": 0,
+ "tv_series_total": 0,
+ "tv_series_skipped": 0,
+ "movies_processed": 0,
+ "movies_total": 0,
+ "movies_skipped": 0,
+ "current_item": None,
+ "last_update": datetime.now()
+ })
+
+def stop_scan_tracking():
+ """Stop scan tracking"""
+ global scan_status
+
+ scan_status.update({
+ "scanning": False,
+ "scan_type": None,
+ "scan_mode": None,
+ "start_time": None,
+ "current_operation": None,
+ "current_item": None
+ })
+
# ---------------------------
# Route Registration
# ---------------------------
@@ -1826,6 +1976,10 @@ def register_routes(app, dependencies: dict):
async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", scan_mode: str = "smart"):
return await manual_scan(background_tasks, path, scan_type, scan_mode, dependencies)
+ @app.get("/api/scan/status")
+ async def _scan_status():
+ return await get_scan_status()
+
@app.post("/tv/scan-season")
async def _scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest):
return await scan_tv_season(background_tasks, request, dependencies)
@@ -1859,131 +2013,37 @@ def register_routes(app, dependencies: dict):
app.include_router(monitoring_router)
# ---------------------------
- # Web Interface API Routes
+ # Web Interface Moved to Separate Container
# ---------------------------
-
- @app.get("/api/dashboard")
- async def _dashboard_stats():
- """Get dashboard statistics"""
- return await get_dashboard_stats(dependencies)
-
- @app.get("/api/movies")
- async def _movies_list(skip: int = 0, limit: int = 100, has_date: Optional[bool] = None,
- source_filter: Optional[str] = None, search: Optional[str] = None,
- imdb_search: Optional[str] = None):
- """Get paginated movies list with filtering"""
- return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
-
- @app.get("/api/series")
- async def _series_list(skip: int = 0, limit: int = 50, search: Optional[str] = None,
- imdb_search: Optional[str] = None, date_filter: Optional[str] = None,
- source_filter: Optional[str] = None):
- """Get paginated TV series list with filtering"""
- return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter)
-
- @app.get("/api/series/{imdb_id}/episodes")
- async def _series_episodes(imdb_id: str):
- """Get episodes for a specific series"""
- return await get_series_episodes(dependencies, imdb_id)
-
- @app.get("/api/series/sources")
- async def _series_sources():
- """Get list of available episode sources for filtering"""
- return await get_series_sources(dependencies)
-
- @app.get("/api/debug/series-date-distribution")
- async def _debug_series_dates():
- """Debug endpoint showing TV series date distribution"""
- return await debug_series_date_distribution(dependencies)
-
- @app.get("/api/reports/missing-dates")
- async def _missing_dates_report():
- """Get report of content missing dateadded"""
- return await get_missing_dates_report(dependencies)
-
- @app.put("/api/movies/{imdb_id}")
- async def _update_movie(imdb_id: str, request: MovieUpdateRequest):
- """Update movie dateadded"""
- return await update_movie_date(dependencies, imdb_id, request.dateadded, request.source)
-
- @app.put("/api/episodes/{imdb_id}/{season}/{episode}")
- async def _update_episode(imdb_id: str, season: int, episode: int, request: EpisodeUpdateRequest):
- """Update episode dateadded"""
- return await update_episode_date(dependencies, imdb_id, season, episode, request.dateadded, request.source)
-
- @app.post("/api/auth/logout")
- async def _logout(request: Request, response: Response):
- """Logout endpoint - clears session"""
- session_manager = dependencies.get("session_manager")
- if session_manager:
- session_token = request.cookies.get("nfoguard_session")
- if session_token:
- session_manager.delete_session(session_token)
-
- response.delete_cookie("nfoguard_session")
- return {"status": "logged_out", "message": "Session cleared"}
-
- @app.get("/api/auth/status")
- async def _auth_status(request: Request):
- """Check authentication status"""
- auth_enabled = dependencies.get("auth_enabled", False)
-
- if not auth_enabled:
- return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"}
-
- session_manager = dependencies.get("session_manager")
- if not session_manager:
- return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"}
-
- session_token = request.cookies.get("nfoguard_session")
- if session_token:
- username = session_manager.get_session_user(session_token)
- if username:
- return {"authenticated": True, "auth_enabled": True, "username": username}
-
- return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"}
-
- @app.post("/api/bulk/update-source")
- async def _bulk_update_source(request: BulkUpdateRequest):
- """Bulk update source for movies or episodes"""
- return await bulk_update_source(dependencies, request.media_type, request.old_source, request.new_source)
-
- @app.get("/api/movies/{imdb_id}/date-options")
- async def _movie_date_options(imdb_id: str):
- """Get available date options for a movie"""
- return await get_movie_date_options(dependencies, imdb_id)
-
- @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
- async def _episode_date_options(imdb_id: str, season: int, episode: int):
- """Get available date options for an episode"""
- return await get_episode_date_options(dependencies, imdb_id, season, episode)
-
- @app.get("/api/debug/movie/{imdb_id}/raw")
- async def _debug_movie_raw(imdb_id: str):
- """Debug endpoint to see raw movie database data"""
- db = dependencies["db"]
- movie = db.get_movie_dates(imdb_id)
- if not movie:
- raise HTTPException(status_code=404, detail="Movie not found")
- return {"raw_data": dict(movie), "imdb_id": imdb_id}
+ # Web interface routes have been moved to the nfoguard-web container
+ # for performance isolation. The core container only handles:
+ # - Webhooks (/webhook/*)
+ # - Manual scans (/manual/*)
+ # - Database operations (/database/*)
+ # - Health checks (/health)
+ #
+ # Web interface available on separate container port 8081
# ---------------------------
- # Static Web Interface
+ # Core API - No Web Interface
# ---------------------------
- from fastapi.staticfiles import StaticFiles
- from fastapi.responses import FileResponse
- import os
-
- # Serve static files for web interface
- static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
- if os.path.exists(static_dir):
- app.mount("/static", StaticFiles(directory=static_dir), name="static")
-
@app.get("/")
- async def _serve_index():
- """Serve web interface index page"""
- index_path = os.path.join(static_dir, "index.html")
- if os.path.exists(index_path):
- return FileResponse(index_path)
- else:
- return {"message": "NFOGuard Web Interface - API endpoints available at /api/", "api_docs": "/docs"}
\ No newline at end of file
+ async def _core_info():
+ """Core container API information - Web interface on separate container"""
+ import os
+ # Get configured web port from environment or config
+ web_port = os.environ.get("WEB_EXTERNAL_PORT",
+ getattr(dependencies.get("config", None), "web_api_port", "8081"))
+
+ return {
+ "service": "NFOGuard Core Processing Engine",
+ "version": "2.7.0",
+ "message": f"Web interface available on separate container (port {web_port})",
+ "api_endpoints": {
+ "health": "/health",
+ "webhooks": "/webhook/*",
+ "manual_scans": "/manual/*",
+ "database": "/database/*",
+ "api_docs": "/docs"
+ }
+ }
\ No newline at end of file
diff --git a/api/web_routes.py b/api/web_routes.py
index 302352f..81d3732 100644
--- a/api/web_routes.py
+++ b/api/web_routes.py
@@ -1213,4 +1213,197 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
"episode": episode,
"current_data": episode_data,
"options": options
- }
\ No newline at end of file
+ }
+
+
+def register_web_routes(app, dependencies):
+ """Register all web API routes with FastAPI app"""
+ from fastapi import Request, Response
+
+ # Dashboard and stats endpoints
+ @app.get("/api/dashboard")
+ async def api_dashboard():
+ return await get_dashboard_stats(dependencies)
+
+ @app.get("/api/dashboard/stats")
+ async def api_dashboard_stats():
+ return await get_dashboard_stats(dependencies)
+
+ # Movies endpoints
+ @app.get("/api/movies")
+ async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None,
+ source_filter: str = None, search: str = None, imdb_search: str = None):
+ return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
+
+ @app.post("/api/movies/{imdb_id}/update-date")
+ async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"):
+ return {"error": "Updates not available in web container. Use core container on port 8085."}
+
+ @app.put("/api/movies/{imdb_id}")
+ async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"):
+ return {"error": "Updates not available in web container. Use core container on port 8085."}
+
+ @app.get("/api/movies/{imdb_id}/date-options")
+ async def api_movie_date_options(imdb_id: str):
+ return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."}
+
+ # TV series endpoints
+ @app.get("/api/series")
+ async def api_series_list(skip: int = 0, limit: int = 50, search: str = None,
+ imdb_search: str = None, date_filter: str = None, source_filter: str = None):
+ return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter)
+
+ @app.get("/api/series/{imdb_id}/episodes")
+ async def api_series_episodes(imdb_id: str):
+ return await get_series_episodes(dependencies, imdb_id)
+
+ @app.get("/api/series/sources")
+ async def api_series_sources():
+ return await get_series_sources(dependencies)
+
+ @app.get("/api/series/debug/date-distribution")
+ async def api_debug_series_date_distribution():
+ return await debug_series_date_distribution(dependencies)
+
+ # Episode endpoints
+ @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
+ async def api_update_episode_date(imdb_id: str, season: int, episode: int,
+ dateadded: str = None, source: str = "manual"):
+ return {"error": "Updates not available in web container. Use core container on port 8085."}
+
+ @app.put("/api/episodes/{imdb_id}/{season}/{episode}")
+ async def api_update_episode(imdb_id: str, season: int, episode: int,
+ dateadded: str = None, source: str = "manual"):
+ return {"error": "Updates not available in web container. Use core container on port 8085."}
+
+ @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
+ async def api_episode_date_options(imdb_id: str, season: int, episode: int):
+ return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."}
+
+ # Bulk operations
+ @app.post("/api/bulk/update-source")
+ async def api_bulk_update_source(media_type: str, old_source: str, new_source: str):
+ return {"error": "Bulk operations not available in web container. Use core container on port 8085."}
+
+ # Reports
+ @app.get("/api/reports/missing-dates")
+ async def api_missing_dates_report():
+ return await get_missing_dates_report(dependencies)
+
+ # Authentication endpoints (for web interface compatibility)
+ @app.get("/api/auth/status")
+ async def api_auth_status(request: Request):
+ """Check authentication status"""
+ auth_enabled = dependencies.get("auth_enabled", False)
+
+ if not auth_enabled:
+ return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"}
+
+ session_manager = dependencies.get("session_manager")
+ if not session_manager:
+ return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"}
+
+ session_token = request.cookies.get("nfoguard_session")
+ if session_token:
+ username = session_manager.get_session_user(session_token)
+ if username:
+ return {"authenticated": True, "auth_enabled": True, "username": username}
+
+ return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"}
+
+ @app.post("/api/auth/logout")
+ async def api_auth_logout(request: Request, response: Response):
+ """Logout endpoint - clears session"""
+ session_manager = dependencies.get("session_manager")
+ if session_manager:
+ session_token = request.cookies.get("nfoguard_session")
+ if session_token:
+ session_manager.delete_session(session_token)
+
+ response.delete_cookie("nfoguard_session")
+ return {"status": "logged_out", "message": "Session cleared"}
+
+ # Manual scan endpoints (proxy to core container)
+ @app.post("/manual/scan")
+ async def api_manual_scan(request: Request):
+ """Proxy manual scan requests to core container"""
+ import urllib.request
+ import urllib.parse
+ import urllib.error
+ import json
+ import os
+ import socket
+
+ # Get core container URL
+ core_host = os.environ.get("CORE_API_HOST", "nfoguard-core")
+ core_port = os.environ.get("CORE_API_PORT", "8080")
+
+ # Forward query parameters from the request
+ query_string = str(request.url.query) if request.url.query else ""
+ core_url = f"http://{core_host}:{core_port}/manual/scan"
+ if query_string:
+ core_url += f"?{query_string}"
+
+ try:
+ # 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:
+ response_data = response.read().decode('utf-8')
+ return json.loads(response_data)
+
+ except urllib.error.HTTPError as e:
+ raise HTTPException(status_code=e.code, detail=f"Core container HTTP error: {e.reason}")
+ except urllib.error.URLError as e:
+ raise HTTPException(status_code=503, detail=f"Could not connect to core container: {str(e)}")
+ except socket.timeout:
+ raise HTTPException(status_code=504, detail="Core container request timed out")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Manual scan request failed: {str(e)}")
+
+ # Simple scan tracking (since we can't reliably access docker logs from container)
+ scan_tracking = {"last_scan_time": None, "scanning": False}
+
+ @app.post("/api/scan/track")
+ async def track_scan_start():
+ """Called when a scan is initiated to track timing"""
+ from datetime import datetime
+ scan_tracking["last_scan_time"] = datetime.now()
+ scan_tracking["scanning"] = True
+ return {"status": "tracked"}
+
+ @app.get("/api/scan/status")
+ async def api_scan_status():
+ """Proxy scan status requests to core container for detailed progress"""
+ import urllib.request
+ import urllib.error
+ import json
+ import os
+ import socket
+
+ # Get core container connection details
+ core_host = os.environ.get("CORE_API_HOST", "nfoguard-core")
+ core_port = os.environ.get("CORE_API_PORT", "8080")
+
+ try:
+ # Call core container's detailed scan status endpoint
+ status_url = f"http://{core_host}:{core_port}/api/scan/status"
+ req = urllib.request.Request(status_url)
+
+ with urllib.request.urlopen(req, timeout=5) as response:
+ status_data = json.loads(response.read().decode())
+ return status_data
+
+ except urllib.error.HTTPError as e:
+ if e.code == 404:
+ # Core container doesn't have the endpoint, fallback to simple tracking
+ return {"scanning": False, "message": "Detailed status not available"}
+ else:
+ return {"scanning": False, "message": f"Core container error: {e.code}"}
+ except (urllib.error.URLError, socket.timeout):
+ return {"scanning": False, "message": "Core container unavailable"}
+ except json.JSONDecodeError:
+ return {"scanning": False, "message": "Invalid response from core container"}
+ except Exception as e:
+ return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"}
\ No newline at end of file
diff --git a/config/settings.py b/config/settings.py
index a49dad0..c9b3505 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -45,6 +45,9 @@ class NFOGuardConfig:
def _load_configuration(self) -> None:
"""Load all configuration from environment variables"""
+ # Server configuration
+ self._load_server_settings()
+
# Core paths - Required
self._load_paths()
@@ -118,6 +121,16 @@ class NFOGuardConfig:
current_value=movie_paths_env
)
+ def _load_server_settings(self) -> None:
+ """Load server configuration"""
+ # Core API settings (webhooks, processing, database management)
+ self.core_api_host = os.environ.get("CORE_API_HOST", "0.0.0.0")
+ self.core_api_port = self._get_int_env("CORE_API_PORT", 8080, 1024, 65535)
+
+ # Web API settings (dashboard, web interface) - for reference/connection
+ self.web_api_host = os.environ.get("WEB_API_HOST", "0.0.0.0")
+ self.web_api_port = self._get_int_env("WEB_API_PORT", 8081, 1024, 65535)
+
def _load_external_connections(self) -> None:
"""Load external API and database connection settings"""
# API URLs
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
new file mode 100644
index 0000000..8240232
--- /dev/null
+++ b/docker-compose.example.yml
@@ -0,0 +1,125 @@
+# NFOGuard Production Docker Compose - 3-Container Architecture
+#
+# RECOMMENDED SETUP: Separated core processing and web interface for optimal performance
+#
+# This is the default configuration providing:
+# - Performance isolation between web and processing
+# - Webhook responsiveness during scans
+# - Independent scaling and updates
+# - Professional web interface with branding
+#
+# For legacy single-container setup, see: docker-compose.legacy-single.yml
+
+services:
+ # PostgreSQL Database
+ nfoguard-db:
+ image: postgres:15-alpine
+ container_name: nfoguard-db
+ restart: unless-stopped
+ env_file:
+ - .env
+ - .env.secrets
+ environment:
+ - POSTGRES_DB=${DB_NAME}
+ - POSTGRES_USER=${DB_USER}
+ - POSTGRES_PASSWORD=${DB_PASSWORD}
+ - TZ=${TZ}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "${DB_EXTERNAL_PORT:-5432}:5432" # Optional external access
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-nfoguard} -d ${DB_NAME:-nfoguard}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - nfoguard-network
+
+ # NFOGuard Core (Processing Engine)
+ nfoguard:
+ image: sbcrumb/nfoguard:latest
+ container_name: nfoguard-core
+ restart: unless-stopped
+ env_file:
+ - .env
+ - .env.secrets
+ environment:
+ - TZ=${TZ}
+ - WEB_EXTERNAL_PORT=${WEB_EXTERNAL_PORT}
+ volumes:
+ # Media paths (adjust to your setup)
+ - /mnt/unionfs/Media/TV:/media/TV:ro
+ - /mnt/unionfs/Media/Movies:/media/Movies:ro
+ # Data persistence
+ - nfoguard_data:/app/data
+ # Logs
+ - nfoguard_logs:/app/data/logs
+ ports:
+ - "${CORE_API_PORT:-8080}:8080" # Core API (webhooks, processing)
+ depends_on:
+ nfoguard-db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+ networks:
+ - nfoguard-network
+
+ # NFOGuard Web Interface
+ nfoguard-web:
+ image: sbcrumb/nfoguard:latest # Same image as core!
+ container_name: nfoguard-web
+ restart: unless-stopped
+ command: ["python", "start_web.py"] # Different entry point
+ env_file:
+ - .env
+ - .env.secrets
+ environment:
+ - TZ=${TZ:-America/New_York}
+ ports:
+ - "${WEB_API_PORT:-8081}:8081" # Web Interface
+ depends_on:
+ nfoguard-db:
+ condition: service_healthy
+ nfoguard:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8081/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+ networks:
+ - nfoguard-network
+
+volumes:
+ postgres_data:
+ driver: local
+ nfoguard_data:
+ driver: local
+ nfoguard_logs:
+ driver: local
+
+networks:
+ nfoguard-network:
+ driver: bridge
+
+# Configuration Notes:
+# 1. Core Processing (nfoguard): Handles webhooks, scanning, NFO management
+# 2. Web Interface (nfoguard-web): Lightweight dashboard and management
+# 3. Database (nfoguard-db): Shared PostgreSQL database
+#
+# Port Configuration:
+# - Core API: ${CORE_API_PORT:-8080} (webhooks, processing)
+# - Web Interface: ${WEB_API_PORT:-8081} (dashboard)
+# - Database: ${DB_EXTERNAL_PORT:-5432} (optional external access)
+#
+# Performance Benefits:
+# - Web interface operations don't impact core processing
+# - Webhooks remain responsive during long scans
+# - Independent scaling and resource allocation
+# - Separated concerns for maintenance and updates
\ No newline at end of file
diff --git a/docker-compose.example b/docker-compose.legacy-single.yml
similarity index 93%
rename from docker-compose.example
rename to docker-compose.legacy-single.yml
index ef6f168..708eb4d 100644
--- a/docker-compose.example
+++ b/docker-compose.legacy-single.yml
@@ -1,3 +1,14 @@
+# NFOGuard Legacy Single-Container Configuration
+#
+# DEPRECATED: This is the legacy single-container setup where web interface
+# and core processing run in the same container, which can cause performance
+# issues during intensive scans.
+#
+# RECOMMENDED: Use docker-compose.example.yml for the new 3-container
+# architecture with better performance isolation.
+#
+# This file is maintained for backward compatibility and migration purposes.
+
version: '3.8'
services:
diff --git a/logo/NFOGuardLogo.png b/logo/NFOGuardLogo.png
new file mode 100644
index 0000000..7c93cac
Binary files /dev/null and b/logo/NFOGuardLogo.png differ
diff --git a/logo/NFOguardLogoPlain.png b/logo/NFOguardLogoPlain.png
new file mode 100644
index 0000000..7c93cac
Binary files /dev/null and b/logo/NFOguardLogoPlain.png differ
diff --git a/main.py b/main.py
index 9b95bfb..5ca49c0 100644
--- a/main.py
+++ b/main.py
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""
-NFOGuard - Automated NFO file management for Radarr and Sonarr
-Modular architecture with webhook processing and intelligent date handling
+NFOGuard Core - Automated NFO file management and processing engine
+Core processing container with webhooks, scanning, and database management
+Web interface separated to nfoguard-web container
"""
import os
import sys
@@ -16,8 +17,7 @@ from fastapi import FastAPI
# Import configuration first
from config.settings import config
-# Import authentication
-from api.auth import SimpleAuthMiddleware, create_auth_dependencies
+# Authentication removed - handled by separate web container
from utils.logging import _log
# Import core components
@@ -175,16 +175,8 @@ def main():
# Initialize components
dependencies = initialize_components()
- # Add authentication dependencies
- auth_deps = create_auth_dependencies(config)
- dependencies.update(auth_deps)
-
- # Add authentication middleware if enabled
- if config.web_auth_enabled:
- app.add_middleware(SimpleAuthMiddleware, config=config)
- _log("INFO", f"Web authentication enabled for user: {config.web_auth_username}")
- else:
- _log("INFO", "Web authentication disabled - web interface is public")
+ # Note: Authentication and web interface handled by separate nfoguard-web container
+ _log("INFO", "Core API: Authentication handled by separate web container")
# Store dependencies globally for signal handler access
signal_handler.dependencies = dependencies
@@ -193,10 +185,16 @@ def main():
register_routes(app, dependencies)
try:
+ # Core API configuration (webhooks, processing, database management)
+ core_host = config.core_api_host if hasattr(config, 'core_api_host') else "0.0.0.0"
+ core_port = config.core_api_port if hasattr(config, 'core_api_port') else 8080
+
+ _log("INFO", f"๐ Starting NFOGuard Core API on {core_host}:{core_port}")
+
uvicorn.run(
app,
- host="0.0.0.0",
- port=int(os.environ.get("PORT", "8080")),
+ host=core_host,
+ port=core_port,
reload=False,
access_log=False, # Reduce logging overhead
server_header=False, # Reduce response overhead
diff --git a/nfoguard-web/__init__.py b/nfoguard-web/__init__.py
new file mode 100644
index 0000000..0adb35b
--- /dev/null
+++ b/nfoguard-web/__init__.py
@@ -0,0 +1 @@
+# NFOGuard Web Interface Package
\ No newline at end of file
diff --git a/nfoguard-web/api/__init__.py b/nfoguard-web/api/__init__.py
new file mode 100644
index 0000000..f272338
--- /dev/null
+++ b/nfoguard-web/api/__init__.py
@@ -0,0 +1 @@
+# NFOGuard Web API Package
\ No newline at end of file
diff --git a/nfoguard-web/api/auth.py b/nfoguard-web/api/auth.py
new file mode 100644
index 0000000..9688f0e
--- /dev/null
+++ b/nfoguard-web/api/auth.py
@@ -0,0 +1,182 @@
+"""
+Simple authentication middleware for NFOGuard web interface
+Provides basic HTTP auth and session management for web interface protection
+"""
+import secrets
+import hashlib
+from datetime import datetime, timedelta
+from typing import Optional, Dict, Any
+from fastapi import HTTPException, status, Request, Response
+from fastapi.security import HTTPBasic, HTTPBasicCredentials
+from starlette.middleware.base import BaseHTTPMiddleware
+
+
+class AuthSession:
+ """Simple session management for web interface"""
+
+ def __init__(self, timeout_seconds: int = 3600):
+ self.sessions: Dict[str, Dict[str, Any]] = {}
+ self.timeout_seconds = timeout_seconds
+
+ def create_session(self, username: str) -> str:
+ """Create a new session and return session token"""
+ session_token = secrets.token_urlsafe(32)
+ self.sessions[session_token] = {
+ "username": username,
+ "created_at": datetime.utcnow(),
+ "last_activity": datetime.utcnow()
+ }
+ return session_token
+
+ def validate_session(self, session_token: str) -> bool:
+ """Validate session token and update last activity"""
+ if not session_token or session_token not in self.sessions:
+ return False
+
+ session = self.sessions[session_token]
+ now = datetime.utcnow()
+
+ # Check if session expired
+ if (now - session["last_activity"]).seconds > self.timeout_seconds:
+ del self.sessions[session_token]
+ return False
+
+ # Update last activity
+ session["last_activity"] = now
+ return True
+
+ def get_session_user(self, session_token: str) -> Optional[str]:
+ """Get username from valid session"""
+ if self.validate_session(session_token):
+ return self.sessions[session_token]["username"]
+ return None
+
+ def delete_session(self, session_token: str) -> None:
+ """Delete a session (logout)"""
+ if session_token in self.sessions:
+ del self.sessions[session_token]
+
+ def cleanup_expired_sessions(self) -> None:
+ """Remove expired sessions"""
+ now = datetime.utcnow()
+ expired_tokens = []
+
+ for token, session in self.sessions.items():
+ if (now - session["last_activity"]).seconds > self.timeout_seconds:
+ expired_tokens.append(token)
+
+ for token in expired_tokens:
+ del self.sessions[token]
+
+
+class SimpleAuthMiddleware(BaseHTTPMiddleware):
+ """Simple authentication middleware for web interface routes"""
+
+ def __init__(self, app, config):
+ super().__init__(app)
+ self.config = config
+ self.session_manager = AuthSession(config.web_auth_session_timeout)
+ self.security = HTTPBasic()
+
+ # Routes that require authentication (web interface)
+ self.protected_routes = [
+ "/", # Main web interface
+ "/static/", # Static files (CSS, JS)
+ "/api/movies", # Web API endpoints
+ "/api/series",
+ "/api/episodes",
+ "/api/dashboard"
+ ]
+
+ # Routes that are always public (webhooks, health checks, API endpoints)
+ self.public_routes = [
+ "/webhook/",
+ "/health",
+ "/ping",
+ "/api/v1/health",
+ "/api/v1/metrics",
+ "/database/", # Database management endpoints (API access)
+ "/manual/", # Manual scan endpoints (API access)
+ "/debug/", # Debug endpoints (API access)
+ "/test/", # Test endpoints (API access)
+ "/bulk/" # Bulk operation endpoints (API access)
+ ]
+
+ async def dispatch(self, request: Request, call_next):
+ """Process request through authentication middleware"""
+
+ # Skip authentication if disabled
+ if not self.config.web_auth_enabled:
+ return await call_next(request)
+
+ # Check if route requires authentication
+ path = request.url.path
+ needs_auth = any(path.startswith(route) for route in self.protected_routes)
+ is_public = any(path.startswith(route) for route in self.public_routes)
+
+ if is_public or not needs_auth:
+ return await call_next(request)
+
+ # Check for existing session
+ session_token = request.cookies.get("nfoguard_session")
+ if session_token and self.session_manager.validate_session(session_token):
+ # Valid session, proceed
+ return await call_next(request)
+
+ # Check for HTTP Basic Auth
+ auth_header = request.headers.get("authorization")
+ if auth_header and auth_header.startswith("Basic "):
+ credentials = self._parse_basic_auth(auth_header)
+ if credentials and self._validate_credentials(credentials.username, credentials.password):
+ # Create session for successful login
+ session_token = self.session_manager.create_session(credentials.username)
+ response = await call_next(request)
+ response.set_cookie(
+ key="nfoguard_session",
+ value=session_token,
+ max_age=self.config.web_auth_session_timeout,
+ httponly=True,
+ secure=False # Set to True if using HTTPS
+ )
+ return response
+
+ # Authentication required
+ return self._auth_required_response()
+
+ def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
+ """Parse HTTP Basic Auth header"""
+ try:
+ import base64
+ encoded_credentials = auth_header.split(" ")[1]
+ decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
+ username, password = decoded_credentials.split(":", 1)
+ return HTTPBasicCredentials(username=username, password=password)
+ except Exception:
+ return None
+
+ def _validate_credentials(self, username: str, password: str) -> bool:
+ """Validate username and password"""
+ return (username == self.config.web_auth_username and
+ password == self.config.web_auth_password)
+
+ def _auth_required_response(self) -> Response:
+ """Return 401 response with WWW-Authenticate header"""
+ return Response(
+ content="Authentication required",
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
+ )
+
+
+def create_auth_dependencies(config) -> Dict[str, Any]:
+ """Create authentication-related dependencies for dependency injection"""
+ session_manager = AuthSession(config.web_auth_session_timeout)
+
+ return {
+ "session_manager": session_manager,
+ "auth_enabled": config.web_auth_enabled,
+ "auth_config": {
+ "username": config.web_auth_username,
+ "timeout": config.web_auth_session_timeout
+ }
+ }
\ No newline at end of file
diff --git a/nfoguard-web/api/web_routes.py b/nfoguard-web/api/web_routes.py
new file mode 100644
index 0000000..6e197a5
--- /dev/null
+++ b/nfoguard-web/api/web_routes.py
@@ -0,0 +1,1219 @@
+"""
+Web interface API routes for NFOGuard database management
+Provides endpoints for the web-based database manipulation interface
+"""
+import json
+from datetime import datetime, timezone
+from typing import List, Optional, Dict, Any
+from fastapi import HTTPException, Query
+from pathlib import Path
+
+import sys
+import os
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from api.models import *
+
+
+def map_source_to_description(source: str) -> str:
+ """Map technical source codes to user-friendly descriptions"""
+ if not source or source == "no_valid_date_source":
+ return "Unknown"
+
+ # Handle different source patterns
+ source_lower = source.lower()
+
+ # TMDB sources
+ if "tmdb:theatrical" in source_lower:
+ return "TMDB Theatrical"
+ elif "tmdb:digital" in source_lower:
+ return "TMDB Digital"
+ elif "tmdb:physical" in source_lower:
+ return "TMDB Physical/DVD"
+ elif "tmdb:" in source_lower:
+ return "TMDB Release"
+
+ # Radarr sources
+ elif "radarr:db.history.import" in source_lower:
+ return "Radarr Import History"
+ elif "radarr:db.file.dateadded" in source_lower:
+ return "Radarr File Date"
+ elif "radarr:nfo.premiered" in source_lower:
+ return "Radarr NFO"
+ elif "radarr:" in source_lower:
+ return "Radarr"
+
+ # OMDb sources
+ elif "omdb:dvd" in source_lower:
+ return "OMDb DVD"
+ elif "omdb:" in source_lower:
+ return "OMDb Release"
+
+ # Manual and other sources
+ elif "manual" in source_lower:
+ return "Manual Entry"
+ elif "digital_release" in source_lower:
+ return "Digital Release"
+ elif "nfo:" in source_lower:
+ return "NFO File"
+ elif "webhook:" in source_lower:
+ return "Webhook/API"
+
+ # Fallback for unknown patterns
+ return source.title()
+
+
+# ---------------------------
+# Database Query Endpoints
+# ---------------------------
+
+async def get_movies_list(dependencies: dict,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(100, le=1000),
+ has_date: Optional[bool] = Query(None),
+ source_filter: Optional[str] = Query(None),
+ search: Optional[str] = Query(None),
+ imdb_search: Optional[str] = Query(None)):
+ """Get paginated list of movies with filtering options"""
+ db = dependencies["db"]
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Build dynamic query
+ where_conditions = []
+ params = []
+
+ if has_date is not None:
+ if has_date:
+ # PostgreSQL - NULL handling
+ where_conditions.append("dateadded IS NOT NULL")
+ else:
+ # PostgreSQL - NULL handling
+ where_conditions.append("dateadded IS NULL")
+
+ if source_filter:
+ where_conditions.append("source = %s")
+ params.append(source_filter)
+
+ if search:
+ where_conditions.append("(imdb_id LIKE %s OR path LIKE %s)")
+ params.extend([f"%{search}%", f"%{search}%"])
+
+ if imdb_search:
+ where_conditions.append("imdb_id LIKE %s")
+ params.append(f"%{imdb_search}%")
+
+ where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
+
+ # Get total count
+ count_query = f"SELECT COUNT(*) FROM movies WHERE {where_clause}"
+ cursor.execute(count_query, params)
+ total_count = db._get_first_value(cursor.fetchone())
+
+ # Get paginated results - PostgreSQL
+ query = f"""
+ SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
+ FROM movies
+ WHERE {where_clause}
+ ORDER BY last_updated DESC
+ LIMIT %s OFFSET %s
+ """
+ cursor.execute(query, params + [limit, skip])
+
+ movies = []
+ for row in cursor.fetchall():
+ movie = dict(row)
+ # Extract title from path for display
+ try:
+ movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
+ except:
+ movie['title'] = movie['imdb_id']
+ # Map source to user-friendly description
+ movie['source_description'] = map_source_to_description(movie.get('source'))
+ movies.append(movie)
+
+ return {
+ "movies": movies,
+ "total_count": total_count,
+ "page": skip // limit + 1,
+ "pages": (total_count + limit - 1) // limit,
+ "has_next": skip + limit < total_count,
+ "has_prev": skip > 0
+ }
+
+
+async def get_tv_series_list(dependencies: dict,
+ skip: int = Query(0, ge=0),
+ limit: int = Query(50, le=500),
+ search: Optional[str] = Query(None),
+ imdb_search: Optional[str] = Query(None),
+ date_filter: Optional[str] = Query(None),
+ source_filter: Optional[str] = Query(None)):
+ """Get paginated list of TV series with episode counts"""
+ db = dependencies["db"]
+
+ # Validate date_filter values
+ if date_filter and date_filter not in ['complete', 'incomplete', 'none']:
+ raise HTTPException(status_code=422, detail=f"Invalid date_filter: must be 'complete', 'incomplete', or 'none', got '{date_filter}'")
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Build dynamic query
+ where_conditions = []
+ params = []
+ having_conditions = []
+
+ if search:
+ where_conditions.append("(s.imdb_id LIKE %s OR s.path LIKE %s)")
+ params.extend([f"%{search}%", f"%{search}%"])
+
+ if imdb_search:
+ where_conditions.append("s.imdb_id LIKE %s")
+ params.append(f"%{imdb_search}%")
+
+ if source_filter:
+ # Need to check episodes for source filter
+ where_conditions.append("e.source = %s")
+ params.append(source_filter)
+
+ if date_filter:
+ if date_filter == "complete":
+ # All episodes have dates
+ having_conditions.append("COUNT(e.episode) > 0 AND COUNT(CASE WHEN e.dateadded IS NULL THEN 1 END) = 0")
+ elif date_filter == "incomplete":
+ # Some episodes have dates, some don't
+ having_conditions.append("COUNT(e.episode) > 0 AND COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) > 0 AND COUNT(CASE WHEN e.dateadded IS NULL THEN 1 END) > 0")
+ elif date_filter == "none":
+ # No episodes have dates
+ having_conditions.append("COUNT(e.episode) > 0 AND COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) = 0")
+
+ where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
+ having_clause = " AND ".join(having_conditions) if having_conditions else ""
+
+ # Get total count with same filtering logic as main query
+ if having_clause:
+ # When using HAVING clause, need to count filtered results
+ count_query = f"""
+ SELECT COUNT(*) FROM (
+ SELECT s.imdb_id
+ FROM series s
+ LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
+ WHERE {where_clause}
+ GROUP BY s.imdb_id
+ HAVING {having_clause}
+ ) filtered_series
+ """
+ cursor.execute(count_query, params)
+ else:
+ # Simple count when no HAVING clause
+ count_query = f"SELECT COUNT(*) FROM series s WHERE {where_clause}"
+ cursor.execute(count_query, params)
+ total_count = db._get_first_value(cursor.fetchone())
+
+ # Get series with episode statistics
+ having_part = f" HAVING {having_clause}" if having_clause else ""
+ # PostgreSQL query
+ query = f"""
+ SELECT
+ s.imdb_id,
+ s.path,
+ s.last_updated,
+ COUNT(e.episode) as total_episodes,
+ COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) as episodes_with_dates,
+ COUNT(CASE WHEN e.has_video_file = TRUE THEN 1 END) as episodes_with_video
+ FROM series s
+ LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
+ WHERE {where_clause}
+ GROUP BY s.imdb_id, s.path, s.last_updated{having_part}
+ ORDER BY s.last_updated DESC
+ LIMIT %s OFFSET %s
+ """
+ cursor.execute(query, params + [limit, skip])
+
+ series = []
+ for row in cursor.fetchall():
+ series_data = dict(row)
+ # Extract title from path
+ try:
+ series_data['title'] = Path(series_data['path']).name if series_data['path'] else series_data['imdb_id']
+ except:
+ series_data['title'] = series_data['imdb_id']
+ series.append(series_data)
+
+ return {
+ "series": series,
+ "total_count": total_count,
+ "page": skip // limit + 1,
+ "pages": (total_count + limit - 1) // limit,
+ "has_next": skip + limit < total_count,
+ "has_prev": skip > 0
+ }
+
+
+async def get_series_sources(dependencies: dict):
+ """Get unique sources from episodes table for filtering"""
+ db = dependencies["db"]
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT DISTINCT source
+ FROM episodes
+ WHERE source IS NOT NULL AND source != ''
+ ORDER BY source
+ """)
+
+ rows = cursor.fetchall()
+ # PostgreSQL RealDictCursor returns dict-like objects
+ sources = [list(row.values())[0] for row in rows]
+ return {"sources": sources}
+
+
+async def debug_series_date_distribution(dependencies: dict):
+ """Debug function to show TV series date distribution"""
+ db = dependencies["db"]
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Get series with episode date statistics
+ cursor.execute("""
+ SELECT
+ s.imdb_id,
+ s.path,
+ COUNT(e.episode) as total_episodes,
+ COUNT(CASE WHEN e.dateadded IS NOT NULL THEN 1 END) as episodes_with_dates,
+ COUNT(CASE WHEN e.dateadded IS NULL THEN 1 END) as episodes_without_dates
+ FROM series s
+ LEFT JOIN episodes e ON s.imdb_id = e.imdb_id
+ GROUP BY s.imdb_id, s.path
+ HAVING COUNT(e.episode) > 0
+ ORDER BY total_episodes DESC
+ LIMIT 50
+ """)
+
+ series_stats = []
+ complete_count = 0
+ incomplete_count = 0
+ none_count = 0
+
+ for row in cursor.fetchall():
+ stats = dict(row)
+ total = stats['total_episodes']
+ with_dates = stats['episodes_with_dates']
+ without_dates = stats['episodes_without_dates']
+
+ if without_dates == 0:
+ category = "complete"
+ complete_count += 1
+ elif with_dates == 0:
+ category = "none"
+ none_count += 1
+ else:
+ category = "incomplete"
+ incomplete_count += 1
+
+ stats['category'] = category
+ stats['title'] = stats['path'].split('/')[-1] if stats['path'] else stats['imdb_id']
+ series_stats.append(stats)
+
+ return {
+ "series_sample": series_stats[:20], # First 20 for debugging
+ "distribution": {
+ "complete": complete_count,
+ "incomplete": incomplete_count,
+ "none": none_count,
+ "total": complete_count + incomplete_count + none_count
+ }
+ }
+
+
+async def get_series_episodes(dependencies: dict, imdb_id: str):
+ """Get all episodes for a specific TV series"""
+ db = dependencies["db"]
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Get series info - PostgreSQL
+ cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (imdb_id,))
+ series_row = cursor.fetchone()
+ if not series_row:
+ raise HTTPException(status_code=404, detail="Series not found")
+
+ series_info = dict(series_row)
+ try:
+ series_info['title'] = Path(series_info['path']).name if series_info['path'] else imdb_id
+ except:
+ series_info['title'] = imdb_id
+
+ # Get episodes - PostgreSQL
+ cursor.execute("""
+ SELECT season, episode, aired, dateadded, source, has_video_file, last_updated
+ FROM episodes
+ WHERE imdb_id = %s
+ ORDER BY season, episode
+ """, (imdb_id,))
+
+ episodes = []
+ for row in cursor.fetchall():
+ episode = dict(row)
+ # Map source to user-friendly description
+ episode['source_description'] = map_source_to_description(episode.get('source'))
+ episodes.append(episode)
+
+ return {
+ "series": series_info,
+ "episodes": episodes
+ }
+
+
+async def get_missing_dates_report(dependencies: dict):
+ """Generate report of movies and episodes missing dateadded"""
+ db = dependencies["db"]
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Movies without dates - PostgreSQL
+ cursor.execute("""
+ SELECT imdb_id, path, released, source, last_updated
+ FROM movies
+ WHERE dateadded IS NULL OR source = 'no_valid_date_source'
+ ORDER BY last_updated DESC
+ """)
+ movies_missing = []
+ for row in cursor.fetchall():
+ movie = dict(row)
+ try:
+ movie['title'] = Path(movie['path']).name if movie['path'] else movie['imdb_id']
+ except:
+ movie['title'] = movie['imdb_id']
+ # Map source to user-friendly description
+ movie['source_description'] = map_source_to_description(movie.get('source'))
+ movies_missing.append(movie)
+
+ # Episodes without dates - PostgreSQL
+ cursor.execute("""
+ SELECT e.imdb_id, e.season, e.episode, e.aired, e.source, e.last_updated, s.path
+ FROM episodes e
+ JOIN series s ON e.imdb_id = s.imdb_id
+ WHERE e.dateadded IS NULL OR e.source = 'no_valid_date_source'
+ ORDER BY e.last_updated DESC
+ """)
+ episodes_missing = []
+ for row in cursor.fetchall():
+ episode = dict(row)
+ try:
+ episode['series_title'] = Path(episode['path']).name if episode['path'] else episode['imdb_id']
+ except:
+ episode['series_title'] = episode['imdb_id']
+ # Map source to user-friendly description
+ episode['source_description'] = map_source_to_description(episode.get('source'))
+ episodes_missing.append(episode)
+
+ # Summary statistics - PostgreSQL
+ cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NOT NULL")
+ movies_with_dates = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM movies")
+ total_movies = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NOT NULL")
+ episodes_with_dates = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM episodes")
+ total_episodes = db._get_first_value(cursor.fetchone())
+
+ return {
+ "summary": {
+ "movies_with_dates": movies_with_dates,
+ "movies_missing_dates": len(movies_missing),
+ "total_movies": total_movies,
+ "episodes_with_dates": episodes_with_dates,
+ "episodes_missing_dates": len(episodes_missing),
+ "total_episodes": total_episodes
+ },
+ "movies_missing": movies_missing,
+ "episodes_missing": episodes_missing
+ }
+
+
+async def get_dashboard_stats(dependencies: dict):
+ """Get comprehensive dashboard statistics"""
+ db = dependencies["db"]
+
+ # Get basic stats from existing method
+ stats = db.get_stats()
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ # Enhanced statistics - PostgreSQL
+ cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NOT NULL")
+ movies_with_dates = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM movies WHERE dateadded IS NULL OR source = 'no_valid_date_source'")
+ movies_without_dates = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NOT NULL")
+ episodes_with_dates = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM episodes WHERE dateadded IS NULL OR source = 'no_valid_date_source'")
+ episodes_without_dates = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM movies WHERE source = 'no_valid_date_source'")
+ movies_no_valid_source = db._get_first_value(cursor.fetchone())
+
+ cursor.execute("SELECT COUNT(*) FROM episodes WHERE source = 'no_valid_date_source'")
+ episodes_no_valid_source = db._get_first_value(cursor.fetchone())
+
+ # Recent activity (last 7 days)
+ cursor.execute("""
+ SELECT COUNT(*) FROM processing_history
+ WHERE processed_at > NOW() - INTERVAL '7 days'
+ """)
+ recent_activity = db._get_first_value(cursor.fetchone())
+
+ # Source distribution for movies
+ cursor.execute("""
+ SELECT source, COUNT(*) as count
+ FROM movies
+ WHERE source IS NOT NULL
+ GROUP BY source
+ ORDER BY count DESC
+ """)
+ movie_sources = [{"source": list(row.values())[0], "source_description": map_source_to_description(list(row.values())[0]), "count": list(row.values())[1]} for row in cursor.fetchall()]
+
+ # Source distribution for episodes
+ cursor.execute("""
+ SELECT source, COUNT(*) as count
+ FROM episodes
+ WHERE source IS NOT NULL
+ GROUP BY source
+ ORDER BY count DESC
+ """)
+ episode_sources = [{"source": list(row.values())[0], "source_description": map_source_to_description(list(row.values())[0]), "count": list(row.values())[1]} for row in cursor.fetchall()]
+
+ # Calculate total missing dates (movies + episodes)
+ total_missing_dates = movies_without_dates + episodes_without_dates
+
+ # Combine with enhanced stats
+ stats.update({
+ "movies_with_dates": movies_with_dates,
+ "movies_without_dates": movies_without_dates,
+ "movies_missing_dates": movies_without_dates, # Keep for backward compatibility
+ "episodes_with_dates": episodes_with_dates,
+ "episodes_without_dates": episodes_without_dates,
+ "episodes_missing_dates": episodes_without_dates, # Keep for backward compatibility
+ "total_missing_dates": total_missing_dates,
+ "movies_no_valid_source": movies_no_valid_source,
+ "episodes_no_valid_source": episodes_no_valid_source,
+ "recent_activity_count": recent_activity,
+ "movie_sources": movie_sources,
+ "episode_sources": episode_sources
+ })
+
+ return stats
+
+
+# ---------------------------
+# Database Modification Endpoints
+# ---------------------------
+
+async def update_movie_date(dependencies: dict, imdb_id: str, dateadded: Optional[str], source: str):
+ """Update dateadded for a specific movie"""
+ db = dependencies["db"]
+
+ # Debug logging to track the issue
+ print(f"๐ UPDATE_MOVIE_DATE: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
+ print(f" - dateadded type: {type(dateadded)}")
+ print(f" - dateadded repr: {repr(dateadded)}")
+
+ # Validate inputs
+ if not imdb_id or not imdb_id.strip():
+ print(f"โ Invalid imdb_id: {repr(imdb_id)}")
+ raise HTTPException(status_code=422, detail="Invalid IMDb ID")
+
+ if not source or not source.strip():
+ print(f"โ Invalid source: {repr(source)}")
+ raise HTTPException(status_code=422, detail="Invalid source")
+
+ # Validate date format if provided
+ if dateadded:
+ try:
+ from datetime import datetime
+ datetime.fromisoformat(dateadded.replace('Z', '+00:00'))
+ except Exception as e:
+ print(f"โ Invalid dateadded format: {repr(dateadded)} - {e}")
+ raise HTTPException(status_code=422, detail=f"Invalid date format: {dateadded}")
+
+ # Validate movie exists
+ movie = db.get_movie_dates(imdb_id)
+ if not movie:
+ raise HTTPException(status_code=404, detail="Movie not found")
+
+ # Update the date
+ db.upsert_movie_dates(
+ imdb_id=imdb_id,
+ released=movie.get('released'),
+ dateadded=dateadded,
+ source=source,
+ has_video_file=movie.get('has_video_file', False)
+ )
+
+ # Add to processing history
+ try:
+ db.add_processing_history(
+ imdb_id=imdb_id,
+ media_type="movie",
+ event_type="manual_date_update",
+ details={"old_source": movie.get('source'), "new_source": source, "dateadded": dateadded}
+ )
+ except Exception as e:
+ print(f"โ ๏ธ Failed to add processing history: {e}")
+ # Don't fail the entire update for history logging issues
+
+ print(f"โ
Successfully updated movie {imdb_id}")
+ return {"status": "success", "message": f"Updated movie {imdb_id}"}
+
+
+async def update_episode_date(dependencies: dict, imdb_id: str, season: int, episode: int,
+ dateadded: Optional[str], source: str):
+ """Update dateadded for a specific episode"""
+ db = dependencies["db"]
+
+ # Get existing episode
+ episode_data = db.get_episode_date(imdb_id, season, episode)
+ if not episode_data:
+ raise HTTPException(status_code=404, detail="Episode not found")
+
+ # Update the date
+ db.upsert_episode_date(
+ imdb_id=imdb_id,
+ season=season,
+ episode=episode,
+ aired=episode_data.get('aired'),
+ dateadded=dateadded,
+ source=source,
+ has_video_file=episode_data.get('has_video_file', False)
+ )
+
+ # Create/update NFO file with new data
+ nfo_manager = dependencies["nfo_manager"]
+ config = dependencies["config"]
+
+ if config.manage_nfo:
+ try:
+ # Find the series directory based on IMDb ID
+ series_path = None
+ for tv_path in config.tv_paths:
+ for series_dir in Path(tv_path).iterdir():
+ if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower():
+ series_path = series_dir
+ break
+ if series_path:
+ break
+
+ if series_path:
+ season_dir = series_path / config.tv_season_dir_format.format(season=season)
+ if season_dir.exists():
+ nfo_manager.create_episode_nfo(
+ season_dir=season_dir,
+ season_num=season,
+ episode_num=episode,
+ aired=episode_data.get('aired'),
+ dateadded=dateadded,
+ source=source,
+ lock_metadata=config.lock_metadata
+ )
+ print(f"โ
Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}")
+ else:
+ print(f"โ ๏ธ Season directory not found: {season_dir}")
+ else:
+ print(f"โ ๏ธ Series directory not found for {imdb_id}")
+
+ except Exception as e:
+ print(f"โ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}")
+
+ # Add to processing history
+ db.add_processing_history(
+ imdb_id=imdb_id,
+ media_type="episode",
+ event_type="manual_date_update",
+ details={
+ "season": season,
+ "episode": episode,
+ "old_source": episode_data.get('source'),
+ "new_source": source,
+ "dateadded": dateadded
+ }
+ )
+
+ return {"status": "success", "message": f"Updated episode {imdb_id} S{season:02d}E{episode:02d}"}
+
+
+async def bulk_update_source(dependencies: dict, media_type: str, old_source: str, new_source: str):
+ """Bulk update source for movies or episodes"""
+ db = dependencies["db"]
+
+ if media_type not in ["movies", "episodes"]:
+ raise HTTPException(status_code=400, detail="media_type must be 'movies' or 'episodes'")
+
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+
+ if media_type == "movies":
+ # Update movies
+ cursor.execute("UPDATE movies SET source = %s WHERE source = %s", (new_source, old_source))
+ updated_count = cursor.rowcount
+
+ # Add history entries
+ cursor.execute("SELECT imdb_id FROM movies WHERE source = %s", (new_source,))
+ for row in cursor.fetchall():
+ db.add_processing_history(
+ imdb_id=row[0],
+ media_type="movie",
+ event_type="bulk_source_update",
+ details={"old_source": old_source, "new_source": new_source}
+ )
+ else:
+ # Update episodes
+ cursor.execute("UPDATE episodes SET source = %s WHERE source = %s", (new_source, old_source))
+ updated_count = cursor.rowcount
+
+ # Add history entries
+ cursor.execute("SELECT imdb_id, season, episode FROM episodes WHERE source = %s", (new_source,))
+ for row in cursor.fetchall():
+ db.add_processing_history(
+ imdb_id=row[0],
+ media_type="episode",
+ event_type="bulk_source_update",
+ details={
+ "season": row[1],
+ "episode": row[2],
+ "old_source": old_source,
+ "new_source": new_source
+ }
+ )
+
+ return {
+ "status": "success",
+ "message": f"Updated {updated_count} {media_type} from source '{old_source}' to '{new_source}'"
+ }
+
+
+async def get_movie_date_options(dependencies: dict, imdb_id: str):
+ """Get available date options for a movie (Radarr import, digital release, etc.)"""
+ db = dependencies["db"]
+ nfo_manager = dependencies["nfo_manager"]
+
+ # Get current movie data
+ movie = db.get_movie_dates(imdb_id)
+ if not movie:
+ raise HTTPException(status_code=404, detail="Movie not found")
+
+ # Debug logging (can be removed once Smart Fix is working)
+ print(f"๐ DEBUG: Movie data for {imdb_id}:")
+ print(f" - released: {repr(movie.get('released'))}")
+ print(f" - dateadded: {repr(movie.get('dateadded'))}")
+ print(f" - source: {repr(movie.get('source'))}")
+
+ options = []
+
+ # Option 1: Current dateadded (if exists and is different from released)
+ if movie.get('dateadded'):
+ current_source = movie.get('source', 'Unknown')
+ current_date = movie['dateadded']
+
+ # Determine what type of current date this is
+ if 'radarr' in current_source.lower() and 'import' in current_source.lower():
+ label = "Keep Current (Radarr Import Date)"
+ description = f"Keep using Radarr download/import date: {current_date}"
+ elif current_source == 'digital_release':
+ label = "Keep Current (Digital Release)"
+ description = f"Keep using digital release date: {current_date}"
+ elif current_source == 'nfo_file_existing':
+ label = "Keep Current (From Existing NFO)"
+ description = f"Keep using date from existing NFO file: {current_date}"
+ else:
+ label = f"Keep Current ({current_source})"
+ description = f"Keep using current date from {current_source}: {current_date}"
+
+ options.append({
+ "type": "current",
+ "label": label,
+ "date": current_date,
+ "source": current_source,
+ "description": description
+ })
+
+ # Option 2: Released date as digital release (if different from current)
+ if movie.get('released') and movie['released'].strip():
+ try:
+ released_raw = movie['released']
+
+ # Handle different released date formats
+ if 'T' in released_raw:
+ # Already has time component: 2018-07-27T00:00:00+00:00
+ release_date = released_raw
+ else:
+ # Just date: 2018-07-27
+ release_date = f"{released_raw}T00:00:00"
+
+ # Validate the date format
+ from datetime import datetime
+ datetime.fromisoformat(release_date.replace('Z', '+00:00'))
+
+ # Only add if it's different from current dateadded
+ current_dateadded = movie.get('dateadded')
+ current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else ''
+ if not current_dateadded or not current_date_str.startswith(released_raw[:10]): # Compare just the date part
+ options.append({
+ "type": "digital_release",
+ "label": "Use Actual Release Date",
+ "date": release_date,
+ "source": "digital_release",
+ "description": f"Use the movie's actual release date: {released_raw[:10]} (instead of download date)"
+ })
+ except Exception as e:
+ print(f"โ ๏ธ Invalid released date format for {imdb_id}: {movie.get('released')} - {e}")
+ # Don't add this option if the date is invalid
+
+ # Option 3: Manual entry
+ options.append({
+ "type": "manual",
+ "label": "Manual Entry",
+ "date": None,
+ "source": "manual",
+ "description": "Enter custom date and time"
+ })
+
+ # Option 4: Active lookup from external sources
+ try:
+ # Get movie processor and clients from dependencies
+ movie_processor = dependencies.get("movie_processor")
+ if movie_processor and hasattr(movie_processor, 'external_clients'):
+ external_clients = movie_processor.external_clients
+
+ # Check Radarr for import dates
+ if movie_processor.radarr and movie_processor.radarr.enabled:
+ try:
+ radarr_movie = movie_processor.radarr.movie_by_imdb(imdb_id)
+ if radarr_movie:
+ movie_id = radarr_movie.get('id')
+ if movie_id:
+ import_date, source = movie_processor.radarr.get_movie_import_date(movie_id, fallback_to_file_date=True)
+ if import_date and source != "no_valid_date_source":
+ # Check if this is different from current date
+ current_dateadded = movie.get('dateadded')
+ current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else ''
+ if not current_dateadded or not current_date_str.startswith(import_date[:10]):
+ options.append({
+ "type": "radarr_import",
+ "label": f"Radarr Import Date ({source})",
+ "date": import_date,
+ "source": f"radarr:{source}",
+ "description": f"Import date from Radarr: {import_date[:10]} (source: {source})"
+ })
+ except Exception as e:
+ print(f"โ ๏ธ Failed to get Radarr import date for {imdb_id}: {e}")
+
+ # Check TMDB for digital release dates
+ if external_clients.tmdb.enabled:
+ try:
+ digital_release = external_clients.tmdb.get_digital_release_date(imdb_id)
+ if digital_release:
+ # Check if this is different from current date
+ current_dateadded = movie.get('dateadded')
+ current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else ''
+ if not current_dateadded or not current_date_str.startswith(digital_release[:10]):
+ options.append({
+ "type": "tmdb_digital",
+ "label": "TMDB Digital Release",
+ "date": f"{digital_release}T00:00:00",
+ "source": "tmdb:digital_release",
+ "description": f"Digital release date from TMDB: {digital_release}"
+ })
+ except Exception as e:
+ print(f"โ ๏ธ Failed to get TMDB digital release for {imdb_id}: {e}")
+
+ # Check OMDb for additional release info
+ if external_clients.omdb.enabled:
+ try:
+ omdb_details = external_clients.omdb.get_movie_details(imdb_id)
+ if omdb_details and omdb_details.get('Released') and omdb_details['Released'] != 'N/A':
+ from datetime import datetime
+ try:
+ # Parse OMDb date format (e.g., "27 Jul 2018")
+ omdb_date = datetime.strptime(omdb_details['Released'], '%d %b %Y')
+ omdb_iso = omdb_date.strftime('%Y-%m-%d')
+
+ # Check if this is different from current date
+ current_dateadded = movie.get('dateadded')
+ current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else ''
+ if not current_dateadded or not current_date_str.startswith(omdb_iso):
+ options.append({
+ "type": "omdb_release",
+ "label": "OMDb Release Date",
+ "date": f"{omdb_iso}T00:00:00",
+ "source": "omdb:release",
+ "description": f"Release date from OMDb: {omdb_iso}"
+ })
+ except ValueError:
+ # Skip if date parsing fails
+ pass
+ except Exception as e:
+ print(f"โ ๏ธ Failed to get OMDb details for {imdb_id}: {e}")
+
+ except Exception as e:
+ print(f"โ ๏ธ External source lookup failed for {imdb_id}: {e}")
+
+ print(f"๐ DEBUG: Generated {len(options)} options for {imdb_id}:")
+ for i, option in enumerate(options):
+ print(f" Option {i}: {option}")
+
+ return {
+ "imdb_id": imdb_id,
+ "current_data": movie,
+ "options": options
+ }
+
+
+async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int, episode: int):
+ """Get available date options for an episode"""
+ print(f"๐ DEBUG: get_episode_date_options called with imdb_id={imdb_id}, season={season}, episode={episode}")
+ db = dependencies["db"]
+
+ # Validate parameters with enhanced checking
+ try:
+ if not imdb_id or not imdb_id.strip():
+ print(f"โ Invalid imdb_id: '{imdb_id}'")
+ raise HTTPException(status_code=422, detail="Invalid imdb_id parameter")
+
+ # Convert and validate season
+ season = int(season) if isinstance(season, str) else season
+ if season < 0:
+ print(f"โ Invalid season: {season}")
+ raise HTTPException(status_code=422, detail="Season must be >= 0")
+
+ # Convert and validate episode
+ episode = int(episode) if isinstance(episode, str) else episode
+ if episode < 1:
+ print(f"โ Invalid episode: {episode}")
+ raise HTTPException(status_code=422, detail="Episode must be >= 1")
+ except ValueError as e:
+ print(f"โ Parameter conversion error: {e}")
+ raise HTTPException(status_code=422, detail=f"Invalid parameter types: {e}")
+
+ # Get current episode data
+ episode_data = db.get_episode_date(imdb_id, season, episode)
+ print(f"๐ DEBUG: Episode data from DB: {episode_data}")
+ if not episode_data:
+ print(f"โ Episode not found in database: {imdb_id} S{season:02d}E{episode:02d}")
+ raise HTTPException(status_code=404, detail="Episode not found")
+
+ options = []
+
+ # Option 1: Current dateadded (if exists)
+ if episode_data.get('dateadded'):
+ options.append({
+ "type": "current",
+ "label": f"Keep Current ({episode_data.get('source', 'Unknown')})",
+ "date": episode_data['dateadded'],
+ "source": episode_data.get('source', 'manual'),
+ "description": f"Currently using: {episode_data.get('source', 'Unknown')}"
+ })
+
+ # Option 2: Aired date (if exists in database)
+ if episode_data.get('aired'):
+ options.append({
+ "type": "airdate",
+ "label": "Use Air Date",
+ "date": f"{episode_data['aired']}T20:00:00", # Default to 8 PM
+ "source": "airdate",
+ "description": f"Use original air date: {episode_data['aired']}"
+ })
+
+ # Option 3: Active lookup from external sources
+ try:
+ # Get TV processor and clients from dependencies
+ tv_processor = dependencies.get("tv_processor")
+ print(f"๐ DEBUG: tv_processor available: {tv_processor is not None}")
+ if tv_processor:
+ print(f"๐ DEBUG: tv_processor has external_clients: {hasattr(tv_processor, 'external_clients')}")
+ print(f"๐ DEBUG: tv_processor has sonarr: {hasattr(tv_processor, 'sonarr')}")
+
+ if tv_processor and hasattr(tv_processor, 'external_clients'):
+ external_clients = tv_processor.external_clients
+ print(f"๐ DEBUG: external_clients available: {external_clients is not None}")
+ if external_clients:
+ print(f"๐ DEBUG: TMDB enabled: {external_clients.tmdb.enabled if hasattr(external_clients, 'tmdb') else 'No TMDB client'}")
+
+ # Check Sonarr for import dates
+ if tv_processor.sonarr and tv_processor.sonarr.enabled:
+ try:
+ print(f"๐ DEBUG: Attempting Sonarr lookup for {imdb_id}")
+ # Look up the series and episode in Sonarr
+ series_data = tv_processor.sonarr.series_by_imdb(imdb_id)
+
+ # If IMDb lookup fails, try direct series lookup as fallback
+ if not series_data:
+ print(f"๐ DEBUG: IMDb lookup failed, trying direct series lookup")
+ try:
+ # Let's also debug what series are available
+ all_series = tv_processor.sonarr.get_all_series()
+ print(f"๐ DEBUG: Found {len(all_series)} total series in Sonarr")
+
+ # Look for Lincoln Lawyer specifically
+ lincoln_series = [s for s in all_series if 'lincoln' in s.get('title', '').lower()]
+ print(f"๐ DEBUG: Lincoln Lawyer series found: {len(lincoln_series)}")
+ for ls in lincoln_series:
+ print(f" - Title: '{ls.get('title')}', IMDb: '{ls.get('imdbId')}', ID: {ls.get('id')}")
+
+ # Try direct lookup first
+ series_data = tv_processor.sonarr.series_by_imdb_direct(imdb_id)
+
+ # If still no match but we found Lincoln Lawyer series, try fuzzy matching
+ if not series_data and lincoln_series:
+ target_imdb_num = imdb_id.replace('tt', '').lower()
+ print(f"๐ DEBUG: Trying fuzzy match for IMDb number: {target_imdb_num}")
+
+ for ls in lincoln_series:
+ ls_imdb = ls.get('imdbId', '')
+ ls_imdb_num = ls_imdb.replace('tt', '').lower()
+ print(f" - Comparing {target_imdb_num} vs {ls_imdb_num}")
+
+ # Check if IMDb numbers are close (within 10 digits)
+ if ls_imdb_num and target_imdb_num:
+ try:
+ target_num = int(target_imdb_num)
+ ls_num = int(ls_imdb_num)
+ diff = abs(target_num - ls_num)
+ print(f" - Numeric difference: {diff}")
+
+ if diff <= 10: # Allow small IMDb ID differences
+ print(f"โ
Found close IMDb match: {ls_imdb} vs {imdb_id} (diff: {diff})")
+ series_data = ls
+ break
+ except ValueError:
+ continue
+ except Exception as e:
+ print(f"โ ๏ธ Direct series lookup also failed: {e}")
+ import traceback
+ print(f" Traceback: {traceback.format_exc()}")
+
+ print(f"๐ DEBUG: Series data found: {series_data is not None}")
+ if series_data:
+ series_id = series_data.get('id')
+ series_title = series_data.get('title', 'Unknown')
+ print(f"๐ DEBUG: Found series '{series_title}' with ID {series_id}")
+
+ if series_id:
+ # Get episodes for the series
+ print(f"๐ DEBUG: Getting episodes for series {series_id}")
+ episodes = tv_processor.sonarr.episodes_for_series(series_id)
+ print(f"๐ DEBUG: Found {len(episodes)} episodes")
+
+ for ep in episodes:
+ ep_season = ep.get('seasonNumber')
+ ep_episode = ep.get('episodeNumber')
+ # Convert to int for proper comparison (handle both string and int from Sonarr)
+ try:
+ ep_season = int(ep_season) if ep_season is not None else None
+ ep_episode = int(ep_episode) if ep_episode is not None else None
+ except (ValueError, TypeError):
+ continue # Skip episodes with invalid season/episode numbers
+
+ if ep_season == season and ep_episode == episode:
+ episode_id = ep.get('id')
+ ep_title = ep.get('title', 'Unknown')
+ ep_air_date = ep.get('airDate') # Get air date from Sonarr
+ print(f"๐ DEBUG: Found target episode '{ep_title}' with ID {episode_id}, airDate: {ep_air_date}")
+
+ if episode_id:
+ # Get import history for this specific episode
+ print(f"๐ DEBUG: Getting import history for episode {episode_id}")
+ import_date = tv_processor.sonarr.get_episode_import_history(episode_id)
+ print(f"๐ DEBUG: Import date found: {import_date}")
+
+ if import_date:
+ # Check if this is different from current date
+ current_dateadded = episode_data.get('dateadded')
+ current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else ''
+ if not current_dateadded or not current_date_str.startswith(import_date[:10]):
+ options.append({
+ "type": "sonarr_import",
+ "label": "Sonarr Import Date",
+ "date": import_date,
+ "source": "sonarr:import_history",
+ "description": f"Import date from Sonarr: {import_date[:10]}"
+ })
+ print(f"โ
Added Sonarr import option: {import_date[:10]}")
+
+ # If no import date but we have air date from Sonarr, add as air date option
+ if not import_date and ep_air_date:
+ current_aired = episode_data.get('aired', '')
+ current_dateadded = episode_data.get('dateadded', '')
+
+ # Add air date option if different from current or missing
+ if not current_aired or current_aired != ep_air_date:
+ options.append({
+ "type": "sonarr_air",
+ "label": "Sonarr Air Date",
+ "date": f"{ep_air_date}T20:00:00",
+ "source": "sonarr:airdate",
+ "description": f"Air date from Sonarr: {ep_air_date}"
+ })
+ print(f"โ
Added Sonarr air date option: {ep_air_date}")
+
+ # If no dateadded, suggest using air date as import date fallback
+ if not current_dateadded:
+ options.append({
+ "type": "sonarr_air_fallback",
+ "label": "Use Air Date as Import Date",
+ "date": f"{ep_air_date}T20:00:00",
+ "source": "sonarr:aired_fallback",
+ "description": f"Use Sonarr air date as import date: {ep_air_date}"
+ })
+ print(f"โ
Added Sonarr air date fallback option: {ep_air_date}")
+
+ break
+ else:
+ print(f"โ No series found in Sonarr for {imdb_id}")
+ except Exception as e:
+ print(f"โ ๏ธ Failed to get Sonarr import date for {imdb_id} S{season:02d}E{episode:02d}: {e}")
+ import traceback
+ print(f" Traceback: {traceback.format_exc()}")
+
+ # Check TMDB for episode air dates
+ if external_clients.tmdb.enabled:
+ try:
+ print(f"๐ DEBUG: Attempting TMDB lookup for {imdb_id}")
+ # Get TMDB TV series ID from IMDb ID using find endpoint
+ tv_find_result = external_clients.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
+ print(f"๐ DEBUG: TMDB find result: {tv_find_result is not None}")
+ print(f"๐ DEBUG: TMDB raw response: {tv_find_result}")
+
+ # Check both tv_results and tv_episode_results
+ tmdb_id = None
+ tv_title = "Unknown"
+
+ if tv_find_result and tv_find_result.get("tv_results"):
+ tv_results = tv_find_result.get("tv_results", [])
+ print(f"๐ DEBUG: Found {len(tv_results)} TV results")
+
+ if tv_results:
+ tv_show = tv_results[0]
+ tmdb_id = tv_show.get("id")
+ tv_title = tv_show.get("name", "Unknown")
+ print(f"๐ DEBUG: Found TMDB series '{tv_title}' with ID {tmdb_id}")
+
+ # Fallback: Check tv_episode_results for show_id
+ elif tv_find_result and tv_find_result.get("tv_episode_results"):
+ episode_results = tv_find_result.get("tv_episode_results", [])
+ print(f"๐ DEBUG: Found {len(episode_results)} TV episode results")
+
+ if episode_results:
+ tmdb_episode_data = episode_results[0]
+ tmdb_id = tmdb_episode_data.get("show_id")
+ episode_name = tmdb_episode_data.get("name", "Unknown")
+ print(f"๐ DEBUG: Found TMDB series via episode '{episode_name}' with show_id {tmdb_id}")
+
+ if tmdb_id:
+ print(f"๐ DEBUG: Using TMDB ID {tmdb_id} for series lookup")
+
+ # Get episode air date from TMDB
+ print(f"๐ DEBUG: Getting TMDB season {season} episodes for series {tmdb_id}")
+ episodes = external_clients.tmdb.get_tv_season_episodes(tmdb_id, season)
+ print(f"๐ DEBUG: TMDB episodes found: {episodes}")
+
+ if episode in episodes:
+ air_date = episodes[episode]
+ print(f"๐ DEBUG: TMDB air date for S{season:02d}E{episode:02d}: {air_date}")
+
+ if air_date:
+ # Check if this is different from current aired date
+ current_aired = episode_data.get('aired', '')
+ if not current_aired or current_aired != air_date:
+ options.append({
+ "type": "tmdb_air",
+ "label": "TMDB Air Date",
+ "date": f"{air_date}T20:00:00", # Default to 8 PM
+ "source": "tmdb:airdate",
+ "description": f"Air date from TMDB: {air_date}"
+ })
+ print(f"โ
Added TMDB air date option: {air_date}")
+
+ # If no aired date in database, also add this as "Use Air Date" option
+ if not current_aired:
+ options.insert(1, { # Insert after current option
+ "type": "airdate_tmdb",
+ "label": "Use Air Date (TMDB)",
+ "date": f"{air_date}T20:00:00",
+ "source": "airdate",
+ "description": f"Use air date from TMDB: {air_date}"
+ })
+ print(f"โ
Added 'Use Air Date' option from TMDB: {air_date}")
+ else:
+ print(f"โ Episode {episode} not found in TMDB season {season} data")
+ else:
+ print(f"โ No TV series ID found in TMDB for {imdb_id}")
+ except Exception as e:
+ print(f"โ ๏ธ Failed to get TMDB air date for {imdb_id} S{season:02d}E{episode:02d}: {e}")
+ import traceback
+ print(f" Traceback: {traceback.format_exc()}")
+
+ # Check external clients for episode air dates (TVDB, OMDb)
+ if hasattr(external_clients, 'get_episode_air_date'):
+ try:
+ air_date = external_clients.get_episode_air_date(imdb_id, season, episode)
+ if air_date:
+ # Check if this is different from current aired date
+ current_aired = episode_data.get('aired', '')
+ if not current_aired or current_aired != air_date:
+ options.append({
+ "type": "external_air",
+ "label": "External Air Date",
+ "date": f"{air_date}T20:00:00", # Default to 8 PM
+ "source": "external:airdate",
+ "description": f"Air date from external sources: {air_date}"
+ })
+
+ # If no aired date in database and not already added from TMDB, add this as "Use Air Date" option
+ if not current_aired and not any(opt.get('type') == 'airdate_tmdb' for opt in options):
+ options.insert(1, { # Insert after current option
+ "type": "airdate_external",
+ "label": "Use Air Date (External)",
+ "date": f"{air_date}T20:00:00",
+ "source": "airdate",
+ "description": f"Use air date from external sources: {air_date}"
+ })
+ except Exception as e:
+ print(f"โ ๏ธ Failed to get external air date for {imdb_id} S{season:02d}E{episode:02d}: {e}")
+
+ except Exception as e:
+ print(f"โ ๏ธ External source lookup failed for {imdb_id} S{season:02d}E{episode:02d}: {e}")
+
+ # Option 4: Manual entry
+ options.append({
+ "type": "manual",
+ "label": "Manual Entry",
+ "date": None,
+ "source": "manual",
+ "description": "Enter custom date and time"
+ })
+
+ print(f"๐ DEBUG: Generated {len(options)} options for {imdb_id} S{season:02d}E{episode:02d}:")
+ for i, option in enumerate(options):
+ print(f" Option {i}: {option}")
+
+ print(f"๐ DEBUG: Returning result with {len(options)} options")
+ return {
+ "imdb_id": imdb_id,
+ "season": season,
+ "episode": episode,
+ "current_data": episode_data,
+ "options": options
+ }
\ No newline at end of file
diff --git a/nfoguard-web/config/web_settings.py b/nfoguard-web/config/web_settings.py
new file mode 100644
index 0000000..ab223bc
--- /dev/null
+++ b/nfoguard-web/config/web_settings.py
@@ -0,0 +1,70 @@
+"""
+NFOGuard Web Interface Configuration
+Lightweight configuration for web-only container
+"""
+import os
+
+
+def _bool_env(name: str, default: bool = False) -> bool:
+ """Convert environment variable to boolean"""
+ value = os.environ.get(name, "").lower()
+ return value in ("true", "1", "yes", "on")
+
+
+class WebConfig:
+ """Configuration for NFOGuard Web Interface"""
+
+ def __init__(self):
+ self._load_server_settings()
+ self._load_database_settings()
+ self._load_auth_settings()
+ self._load_ui_settings()
+
+ def _load_server_settings(self) -> None:
+ """Load web server configuration"""
+ self.web_host = os.environ.get("WEB_HOST", "0.0.0.0")
+ self.web_port = int(os.environ.get("WEB_PORT", "8081"))
+ self.web_workers = int(os.environ.get("WEB_WORKERS", "1"))
+ self.web_debug = _bool_env("WEB_DEBUG", False)
+
+ # Core NFOGuard API connection (for some operations)
+ self.core_api_host = os.environ.get("CORE_API_HOST", "nfoguard")
+ self.core_api_port = int(os.environ.get("CORE_API_PORT", "8080"))
+ self.core_api_url = f"http://{self.core_api_host}:{self.core_api_port}"
+
+ def _load_database_settings(self) -> None:
+ """Load database configuration (read-only access)"""
+ self.db_type = os.environ.get("DB_TYPE", "postgresql").lower()
+ self.db_host = os.environ.get("DB_HOST", "nfoguard-db")
+ self.db_port = int(os.environ.get("DB_PORT", "5432"))
+ self.db_name = os.environ.get("DB_NAME", "nfoguard")
+ self.db_user = os.environ.get("DB_USER", "nfoguard")
+ self.db_password = os.environ.get("DB_PASSWORD", "")
+
+ if not self.db_password:
+ raise ValueError("DB_PASSWORD must be set for web interface database access")
+
+ def _load_auth_settings(self) -> None:
+ """Load web interface authentication settings"""
+ self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
+ self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin")
+ self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "")
+ self.web_auth_session_timeout = int(os.environ.get("WEB_AUTH_SESSION_TIMEOUT", "3600"))
+
+ if self.web_auth_enabled and not self.web_auth_password:
+ raise ValueError("WEB_AUTH_PASSWORD must be set when authentication is enabled")
+
+ def _load_ui_settings(self) -> None:
+ """Load UI-specific settings"""
+ self.app_title = os.environ.get("APP_TITLE", "NFOGuard")
+ self.app_subtitle = os.environ.get("APP_SUBTITLE", "Database Management & Reporting")
+ self.pagination_limit = int(os.environ.get("PAGINATION_LIMIT", "50"))
+ self.refresh_interval = int(os.environ.get("REFRESH_INTERVAL", "30")) # seconds
+
+ # Logo configuration
+ self.logo_enabled = _bool_env("LOGO_ENABLED", True)
+ self.logo_path = "/static/logo/NFOguardLogoPlain.png"
+
+
+# Global config instance
+web_config = WebConfig()
\ No newline at end of file
diff --git a/nfoguard-web/core/__init__.py b/nfoguard-web/core/__init__.py
new file mode 100644
index 0000000..1d3ff20
--- /dev/null
+++ b/nfoguard-web/core/__init__.py
@@ -0,0 +1 @@
+# NFOGuard Web Core Components
\ No newline at end of file
diff --git a/nfoguard-web/core/web_database.py b/nfoguard-web/core/web_database.py
new file mode 100644
index 0000000..5c95e40
--- /dev/null
+++ b/nfoguard-web/core/web_database.py
@@ -0,0 +1,238 @@
+"""
+NFOGuard Web Database - Lightweight Read-Only Database Access
+Optimized for web interface queries with minimal dependencies
+"""
+import psycopg2
+import psycopg2.extras
+from typing import Dict, List, Optional, Any, Tuple
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class WebDatabase:
+ """Lightweight database access for web interface"""
+
+ def __init__(self, db_type: str, host: str, port: int, database: str, user: str, password: str):
+ self.db_type = db_type.lower()
+ self.host = host
+ self.port = port
+ self.database = database
+ self.user = user
+ self.password = password
+ self.connection = None
+
+ # Connect to database
+ self._connect()
+
+ def _connect(self):
+ """Connect to PostgreSQL database"""
+ if self.db_type != "postgresql":
+ raise ValueError("Web interface only supports PostgreSQL")
+
+ try:
+ self.connection = psycopg2.connect(
+ host=self.host,
+ port=self.port,
+ database=self.database,
+ user=self.user,
+ password=self.password,
+ cursor_factory=psycopg2.extras.RealDictCursor
+ )
+ # Set to autocommit for read operations
+ self.connection.autocommit = True
+ logger.info(f"Connected to PostgreSQL: {self.host}:{self.port}/{self.database}")
+ except Exception as e:
+ logger.error(f"Failed to connect to database: {e}")
+ raise
+
+ def execute_query(self, query: str, params: Optional[Tuple] = None) -> List[Dict[str, Any]]:
+ """Execute a SELECT query and return results"""
+ try:
+ with self.connection.cursor() as cursor:
+ cursor.execute(query, params)
+ return [dict(row) for row in cursor.fetchall()]
+ except Exception as e:
+ logger.error(f"Query failed: {query[:100]}... Error: {e}")
+ raise
+
+ def execute_single(self, query: str, params: Optional[Tuple] = None) -> Optional[Dict[str, Any]]:
+ """Execute a query and return single result"""
+ results = self.execute_query(query, params)
+ return results[0] if results else None
+
+ def execute_scalar(self, query: str, params: Optional[Tuple] = None) -> Any:
+ """Execute a query and return single value"""
+ result = self.execute_single(query, params)
+ return list(result.values())[0] if result else None
+
+ # Dashboard Statistics
+ def get_dashboard_stats(self) -> Dict[str, Any]:
+ """Get dashboard statistics"""
+ stats = {}
+
+ # Movie statistics
+ movie_query = """
+ SELECT
+ COUNT(*) as total_movies,
+ COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as movies_with_dates,
+ COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as movies_without_dates
+ FROM movies
+ """
+ movie_stats = self.execute_single(movie_query)
+ stats.update(movie_stats)
+
+ # TV statistics
+ tv_query = """
+ SELECT
+ COUNT(DISTINCT imdb_id) as total_series,
+ COUNT(*) as total_episodes,
+ COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as episodes_with_dates,
+ COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as episodes_without_dates
+ FROM episodes
+ """
+ tv_stats = self.execute_single(tv_query)
+ stats.update(tv_stats)
+
+ return stats
+
+ # Movie queries
+ def get_movies(self, skip: int = 0, limit: int = 50, has_date: Optional[bool] = None) -> List[Dict[str, Any]]:
+ """Get movies with pagination"""
+ where_clause = ""
+ params = []
+
+ if has_date is not None:
+ if has_date:
+ where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
+ else:
+ where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
+
+ query = f"""
+ SELECT imdb_id, title, year, dateadded, released, source, last_updated
+ FROM movies
+ {where_clause}
+ ORDER BY title, year
+ LIMIT %s OFFSET %s
+ """
+ params.extend([limit, skip])
+
+ return self.execute_query(query, params)
+
+ def get_movie_count(self, has_date: Optional[bool] = None) -> int:
+ """Get total movie count"""
+ where_clause = ""
+ params = []
+
+ if has_date is not None:
+ if has_date:
+ where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
+ else:
+ where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
+
+ query = f"SELECT COUNT(*) FROM movies {where_clause}"
+ return self.execute_scalar(query, params)
+
+ # TV Series queries
+ def get_series(self, skip: int = 0, limit: int = 50, date_filter: str = "none") -> List[Dict[str, Any]]:
+ """Get TV series with episode statistics"""
+ where_clause = ""
+ if date_filter == "complete":
+ where_clause = """
+ WHERE NOT EXISTS (
+ SELECT 1 FROM episodes e2
+ WHERE e2.imdb_id = e.imdb_id
+ AND (e2.dateadded IS NULL OR e2.source = 'unknown')
+ )
+ """
+ elif date_filter == "incomplete":
+ where_clause = """
+ WHERE EXISTS (
+ SELECT 1 FROM episodes e2
+ WHERE e2.imdb_id = e.imdb_id
+ AND (e2.dateadded IS NULL OR e2.source = 'unknown')
+ )
+ """
+
+ query = f"""
+ SELECT
+ e.imdb_id,
+ e.series_title,
+ COUNT(*) as total_episodes,
+ COUNT(CASE WHEN e.dateadded IS NOT NULL AND e.source != 'unknown' THEN 1 END) as episodes_with_dates,
+ COUNT(CASE WHEN e.dateadded IS NULL OR e.source = 'unknown' THEN 1 END) as episodes_without_dates,
+ MAX(e.last_updated) as last_updated
+ FROM episodes e
+ {where_clause}
+ GROUP BY e.imdb_id, e.series_title
+ ORDER BY e.series_title
+ LIMIT %s OFFSET %s
+ """
+
+ return self.execute_query(query, [limit, skip])
+
+ def get_series_count(self, date_filter: str = "none") -> int:
+ """Get total series count"""
+ where_clause = ""
+ if date_filter == "complete":
+ where_clause = """
+ WHERE NOT EXISTS (
+ SELECT 1 FROM episodes e2
+ WHERE e2.imdb_id = e.imdb_id
+ AND (e2.dateadded IS NULL OR e2.source = 'unknown')
+ )
+ """
+ elif date_filter == "incomplete":
+ where_clause = """
+ WHERE EXISTS (
+ SELECT 1 FROM episodes e2
+ WHERE e2.imdb_id = e.imdb_id
+ AND (e2.dateadded IS NULL OR e2.source = 'unknown')
+ )
+ """
+
+ query = f"""
+ SELECT COUNT(DISTINCT imdb_id)
+ FROM episodes e
+ {where_clause}
+ """
+
+ return self.execute_scalar(query)
+
+ def get_episodes_for_series(self, imdb_id: str) -> List[Dict[str, Any]]:
+ """Get all episodes for a series"""
+ query = """
+ SELECT imdb_id, series_title, season, episode, episode_title,
+ dateadded, source, last_updated
+ FROM episodes
+ WHERE imdb_id = %s
+ ORDER BY season, episode
+ """
+ return self.execute_query(query, [imdb_id])
+
+ # Source statistics
+ def get_series_sources(self) -> List[Dict[str, Any]]:
+ """Get source statistics for series"""
+ query = """
+ SELECT
+ source,
+ COUNT(DISTINCT imdb_id) as series_count,
+ COUNT(*) as episode_count
+ FROM episodes
+ WHERE source != 'unknown'
+ GROUP BY source
+ ORDER BY series_count DESC, episode_count DESC
+ """
+ return self.execute_query(query)
+
+ def close(self):
+ """Close database connection"""
+ if self.connection:
+ self.connection.close()
+ logger.info("Database connection closed")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
\ No newline at end of file
diff --git a/nfoguard-web/logo/NFOguardLogoPlain.png b/nfoguard-web/logo/NFOguardLogoPlain.png
new file mode 100644
index 0000000..7c93cac
Binary files /dev/null and b/nfoguard-web/logo/NFOguardLogoPlain.png differ
diff --git a/nfoguard-web/main_web.py b/nfoguard-web/main_web.py
new file mode 100644
index 0000000..07a88e6
--- /dev/null
+++ b/nfoguard-web/main_web.py
@@ -0,0 +1,142 @@
+"""
+NFOGuard Web Interface - Separated Web Application
+Lightweight FastAPI application for web interface only
+"""
+import asyncio
+import signal
+import sys
+import os
+from pathlib import Path
+
+import uvicorn
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+
+# Add current directory and parent directory to path for imports
+sys.path.append(str(Path(__file__).parent))
+sys.path.append(str(Path(__file__).parent.parent))
+
+# Import web-specific configuration
+from config.web_settings import web_config
+
+# Import database (lightweight, read-only access)
+from core.web_database import WebDatabase
+
+# Import web routes and authentication
+from api.web_routes import register_web_routes
+from api.auth import SimpleAuthMiddleware, create_auth_dependencies
+
+
+def create_web_app() -> FastAPI:
+ """Create FastAPI web application"""
+ app = FastAPI(
+ title="NFOGuard Web Interface",
+ description="Web interface for NFOGuard media database management",
+ version="2.6.12-web",
+ docs_url="/docs" if web_config.web_debug else None,
+ redoc_url="/redoc" if web_config.web_debug else None
+ )
+
+ return app
+
+
+def initialize_web_database() -> WebDatabase:
+ """Initialize web database connection (read-only optimized)"""
+ return WebDatabase(
+ db_type=web_config.db_type,
+ host=web_config.db_host,
+ port=web_config.db_port,
+ database=web_config.db_name,
+ user=web_config.db_user,
+ password=web_config.db_password
+ )
+
+
+def setup_static_files(app: FastAPI) -> None:
+ """Mount static file directories"""
+ # Mount main static files
+ app.mount("/static", StaticFiles(directory="static"), name="static")
+
+ # Mount logo separately for easy access
+ app.mount("/logo", StaticFiles(directory="logo"), name="logo")
+
+ # Serve index.html at root
+ @app.get("/")
+ async def serve_index():
+ return FileResponse("static/index.html")
+
+
+def setup_signal_handlers():
+ """Setup graceful shutdown signal handlers"""
+ def signal_handler(signum, frame):
+ print(f"\n๐ Received signal {signum}, shutting down web interface...")
+ # Web interface can shutdown immediately (no background processing)
+ sys.exit(0)
+
+ signal.signal(signal.SIGTERM, signal_handler)
+ signal.signal(signal.SIGINT, signal_handler)
+
+
+def main():
+ """Main entry point for NFOGuard Web Interface"""
+ print("๐ Starting NFOGuard Web Interface...")
+ print(f"๐ Configuration: Port {web_config.web_port}, Auth: {'Enabled' if web_config.web_auth_enabled else 'Disabled'}")
+
+ # Setup signal handlers
+ setup_signal_handlers()
+
+ # Create FastAPI app
+ app = create_web_app()
+
+ # Initialize database
+ try:
+ db = initialize_web_database()
+ print(f"โ
Connected to database: {web_config.db_host}:{web_config.db_port}/{web_config.db_name}")
+ except Exception as e:
+ print(f"โ Failed to connect to database: {e}")
+ sys.exit(1)
+
+ # Create dependencies for dependency injection
+ dependencies = {
+ "db": db,
+ "config": web_config
+ }
+
+ # Add authentication dependencies if enabled
+ if web_config.web_auth_enabled:
+ auth_deps = create_auth_dependencies(web_config)
+ dependencies.update(auth_deps)
+
+ # Add authentication middleware
+ app.add_middleware(SimpleAuthMiddleware, config=web_config)
+ print(f"๐ Web authentication enabled for user: {web_config.web_auth_username}")
+ else:
+ print("๐ Web authentication disabled - interface is public")
+
+ # Setup static files and routes
+ setup_static_files(app)
+
+ # Register web routes
+ register_web_routes(app, dependencies)
+
+ print(f"๐ Starting web server on {web_config.web_host}:{web_config.web_port}")
+
+ try:
+ uvicorn.run(
+ app,
+ host=web_config.web_host,
+ port=web_config.web_port,
+ workers=web_config.web_workers,
+ log_level="debug" if web_config.web_debug else "info",
+ access_log=web_config.web_debug
+ )
+ except KeyboardInterrupt:
+ print("\n๐ Web interface shutdown by user")
+ except Exception as e:
+ print(f"โ Web interface failed: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css
new file mode 100644
index 0000000..ad39481
--- /dev/null
+++ b/nfoguard-web/static/css/styles.css
@@ -0,0 +1,889 @@
+/* NFOGuard Web Interface Styles */
+:root {
+ --primary-color: #007bff;
+ --secondary-color: #6c757d;
+ --success-color: #28a745;
+ --warning-color: #ffc107;
+ --danger-color: #dc3545;
+ --dark-color: #343a40;
+ --light-color: #f8f9fa;
+ --border-color: #dee2e6;
+ --text-muted: #6c757d;
+ --shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
+ --shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--dark-color);
+ background-color: #f5f5f5;
+}
+
+.app-container {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Header */
+.app-header {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 1rem 0;
+ box-shadow: var(--shadow-lg);
+ position: relative;
+}
+
+.header-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 1rem;
+ 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;
+}
+
+.header-content h1 i {
+ margin-right: 0.5rem;
+}
+
+.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 */
+.auth-status {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ color: white;
+ font-size: 0.9rem;
+}
+
+.auth-user {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ opacity: 0.9;
+}
+
+.auth-logout {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ padding: 0.5rem 1rem;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ font-size: 0.85rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: all 0.2s ease;
+}
+
+.auth-logout:hover {
+ background: rgba(255, 255, 255, 0.3);
+ border-color: rgba(255, 255, 255, 0.5);
+ transform: translateY(-1px);
+}
+
+.nav-tabs {
+ max-width: 1200px;
+ margin: 1rem auto 0;
+ padding: 0 1rem;
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.nav-tab {
+ background: rgba(255, 255, 255, 0.1);
+ border: none;
+ color: white;
+ padding: 0.75rem 1.5rem;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-size: 0.9rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.nav-tab:hover {
+ background: rgba(255, 255, 255, 0.2);
+ transform: translateY(-1px);
+}
+
+.nav-tab.active {
+ background: rgba(255, 255, 255, 0.9);
+ color: var(--dark-color);
+}
+
+/* Main Content */
+.main-content {
+ flex: 1;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+ width: 100%;
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Dashboard */
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: white;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.stat-icon {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ color: white;
+}
+
+.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
+.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
+.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
+.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
+
+.stat-info h3 {
+ font-size: 2rem;
+ font-weight: 700;
+ margin-bottom: 0.25rem;
+}
+
+.stat-info p {
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+}
+
+.stat-info small {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.dashboard-charts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1.5rem;
+}
+
+.chart-card {
+ background: white;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+}
+
+.chart-card h3 {
+ margin-bottom: 1rem;
+ color: var(--dark-color);
+}
+
+.chart-container {
+ height: 200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--light-color);
+ border-radius: 0.25rem;
+ color: var(--text-muted);
+}
+
+/* Content Header */
+.content-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.content-header h2 {
+ color: var(--dark-color);
+ font-weight: 600;
+}
+
+.content-controls {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.search-controls {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.filter-controls {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.search-box {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.search-box i {
+ position: absolute;
+ left: 0.75rem;
+ color: var(--text-muted);
+}
+
+.search-box input {
+ padding: 0.5rem 0.75rem 0.5rem 2.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ font-size: 0.9rem;
+ width: 250px;
+}
+
+.search-box input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+/* Buttons */
+.btn {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background-color: var(--primary-color);
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: #0056b3;
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background-color: var(--secondary-color);
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #545b62;
+}
+
+.btn-success {
+ background-color: var(--success-color);
+ color: white;
+}
+
+.btn-success:hover {
+ background-color: #1e7e34;
+}
+
+.btn-warning {
+ background-color: var(--warning-color);
+ color: var(--dark-color);
+}
+
+.btn-warning:hover {
+ background-color: #e0a800;
+}
+
+.btn-danger {
+ background-color: var(--danger-color);
+ color: white;
+}
+
+.btn-danger:hover {
+ background-color: #c82333;
+}
+
+.btn-sm {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.8rem;
+}
+
+/* Tables */
+.table-container {
+ background: white;
+ border-radius: 0.5rem;
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ margin-bottom: 1rem;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.data-table th,
+.data-table td {
+ padding: 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.data-table th {
+ background-color: var(--light-color);
+ font-weight: 600;
+ color: var(--dark-color);
+ position: sticky;
+ top: 0;
+}
+
+.data-table tr:hover {
+ background-color: rgba(0, 123, 255, 0.05);
+}
+
+.data-table .loading {
+ text-align: center;
+ color: var(--text-muted);
+ font-style: italic;
+ padding: 2rem;
+}
+
+/* Status badges */
+.badge {
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.badge-success {
+ background-color: #d4edda;
+ color: #155724;
+}
+
+.badge-warning {
+ background-color: #fff3cd;
+ color: #856404;
+}
+
+.badge-danger {
+ background-color: #f8d7da;
+ color: #721c24;
+}
+
+.badge-secondary {
+ background-color: #e9ecef;
+ color: #495057;
+}
+
+/* Pagination */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.pagination .btn {
+ padding: 0.5rem 0.75rem;
+}
+
+.pagination .page-info {
+ margin: 0 1rem;
+ color: var(--text-muted);
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.25rem;
+ font-weight: 500;
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: 0.25rem;
+ font-size: 0.9rem;
+}
+
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.form-group small {
+ display: block;
+ margin-top: 0.25rem;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+.form-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1.5rem;
+}
+
+/* Modal */
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+}
+
+.modal.active {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-content {
+ background: white;
+ border-radius: 0.5rem;
+ max-width: 500px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg);
+}
+
+.modal-header {
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-header h3 {
+ margin: 0;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ color: var(--text-muted);
+}
+
+.modal-close:hover {
+ color: var(--dark-color);
+}
+
+.modal-body {
+ padding: 1.5rem;
+}
+
+/* Higher z-index for edit modals that appear on top of other modals */
+#edit-modal, #smart-fix-modal {
+ z-index: 1100 !important;
+}
+
+/* Reports */
+.report-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.summary-card {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 0.5rem;
+ box-shadow: var(--shadow);
+ text-align: center;
+}
+
+.summary-card h3 {
+ margin-bottom: 1rem;
+ color: var(--dark-color);
+}
+
+.summary-card p {
+ margin-bottom: 0.5rem;
+ font-size: 1.1rem;
+}
+
+.summary-card span {
+ font-weight: 700;
+ color: var(--primary-color);
+}
+
+.report-section {
+ margin-bottom: 2rem;
+}
+
+.report-section h3 {
+ margin-bottom: 1rem;
+ color: var(--dark-color);
+}
+
+/* Tools */
+.tools-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1.5rem;
+}
+
+.tool-card {
+ background: white;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+}
+
+.tool-card h3 {
+ margin-bottom: 0.5rem;
+ color: var(--dark-color);
+}
+
+.tool-card p {
+ margin-bottom: 1.5rem;
+ color: var(--text-muted);
+}
+
+.stats-display {
+ background: var(--light-color);
+ padding: 1rem;
+ border-radius: 0.25rem;
+ margin-bottom: 1rem;
+ min-height: 100px;
+}
+
+/* Toast notifications */
+.toast-container {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 1050;
+}
+
+.toast {
+ background: white;
+ border-radius: 0.25rem;
+ box-shadow: var(--shadow-lg);
+ margin-bottom: 0.5rem;
+ padding: 0.75rem 1rem;
+ min-width: 300px;
+ border-left: 4px solid var(--primary-color);
+ animation: slideIn 0.3s ease;
+}
+
+.toast.success {
+ border-left-color: var(--success-color);
+}
+
+.toast.warning {
+ border-left-color: var(--warning-color);
+}
+
+.toast.error {
+ border-left-color: var(--danger-color);
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Smart Fix Modal */
+.smart-fix-options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.option-card {
+ border: 2px solid var(--border-color);
+ border-radius: 0.5rem;
+ transition: all 0.2s ease;
+}
+
+.option-card:hover {
+ border-color: var(--primary-color);
+ box-shadow: var(--shadow);
+}
+
+.option-label {
+ display: block;
+ padding: 1rem;
+ cursor: pointer;
+ margin: 0;
+}
+
+.option-label input[type="radio"] {
+ margin-right: 0.75rem;
+ margin-top: 0.1rem;
+ width: auto;
+}
+
+.option-content h4 {
+ margin: 0 0 0.5rem 0;
+ color: var(--dark-color);
+ font-size: 1rem;
+}
+
+.option-content p {
+ margin: 0 0 0.5rem 0;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+}
+
+.option-content small {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+.manual-date-input {
+ width: 100% !important;
+ margin-top: 0.5rem !important;
+}
+
+.option-card input[type="radio"]:checked + .option-content {
+ color: var(--primary-color);
+}
+
+.option-card:has(input[type="radio"]:checked) {
+ border-color: var(--primary-color);
+ background-color: rgba(0, 123, 255, 0.05);
+}
+
+/* Additional badge styles */
+.badge-info {
+ background-color: #d1ecf1;
+ color: #0c5460;
+}
+
+/* Enhanced Edit Modal Date Options */
+.date-options {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.date-option-card {
+ border: 1px solid var(--border-color);
+ border-radius: 0.375rem;
+ transition: all 0.2s ease;
+}
+
+.date-option-card:hover {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
+}
+
+.date-option-label {
+ display: block;
+ padding: 0.75rem;
+ cursor: pointer;
+ margin: 0;
+}
+
+.date-option-label input[type="radio"] {
+ margin-right: 0.5rem;
+ margin-top: 0.1rem;
+ width: auto;
+}
+
+.date-option-content h4 {
+ margin: 0 0 0.25rem 0;
+ color: var(--dark-color);
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.date-option-content p {
+ margin: 0 0 0.25rem 0;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+.date-option-content small {
+ color: var(--primary-color);
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.date-option-card input[type="radio"]:checked + .date-option-content h4 {
+ color: var(--primary-color);
+}
+
+.date-option-card:has(input[type="radio"]:checked) {
+ border-color: var(--primary-color);
+ background-color: rgba(0, 123, 255, 0.03);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .content-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .content-controls {
+ justify-content: center;
+ }
+
+ .search-box input {
+ width: 200px;
+ }
+
+ .nav-tabs {
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .data-table {
+ font-size: 0.8rem;
+ }
+
+ .data-table th,
+ .data-table td {
+ padding: 0.5rem 0.25rem;
+ }
+
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .tools-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Utility classes */
+.text-center { text-align: center; }
+.text-muted { color: var(--text-muted); }
+.mb-0 { margin-bottom: 0; }
+.mb-1 { margin-bottom: 0.5rem; }
+.mb-2 { margin-bottom: 1rem; }
+.mt-1 { margin-top: 0.5rem; }
+.mt-2 { margin-top: 1rem; }
+.d-none { display: none; }
+.d-block { display: block; }
+.d-flex { display: flex; }
+.justify-content-between { justify-content: space-between; }
+.align-items-center { align-items: center; }
\ No newline at end of file
diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html
new file mode 100644
index 0000000..bbc8d68
--- /dev/null
+++ b/nfoguard-web/static/index.html
@@ -0,0 +1,462 @@
+
+
+
+
+
+ NFOGuard - Database Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan in Progress
+
Processing media files...
+
+
+
+
+
+
+
+
+
+
+
+
-
+
Total Movies
+
- with dates
+
+
+
+
+
+
+
+
+
-
+
TV Series
+
- episodes
+
+
+
+
+
+
+
+
+
-
+
Missing Dates
+
- no valid source
+
+
+
+
+
+
+
+
+
-
+
Recent Activity
+
Last 7 days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Title |
+ IMDb ID |
+ Movie Released |
+ Date Added to Library |
+ Source |
+ Date Type |
+ Video File |
+ Actions |
+
+
+
+
+ | Loading movies... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Series Title |
+ IMDb ID |
+ Episodes |
+ With Dates |
+ With Video |
+ Actions |
+
+
+
+
+ | Loading series... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Movies
+
- with dates
+
- missing dates
+
+
+
Episodes
+
- with dates
+
- missing dates
+
+
+
+
+
+
Movies Missing Dates
+
+
+
+
+ | Title |
+ IMDb ID |
+ Released |
+ Source |
+ Smart Fix |
+
+
+
+
+ | Loading report... |
+
+
+
+
+
+
+
+
Episodes Missing Dates
+
+
+
+
+ | Series |
+ Episode |
+ IMDb ID |
+ Aired |
+ Source |
+ Smart Fix |
+
+
+
+
+ | Loading report... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading available options...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js
new file mode 100644
index 0000000..79ee839
--- /dev/null
+++ b/nfoguard-web/static/js/app.js
@@ -0,0 +1,1659 @@
+// NFOGuard Web Interface JavaScript
+
+// Global state
+let currentTab = 'dashboard';
+let currentMoviesPage = 1;
+let currentSeriesPage = 1;
+let dashboardData = null;
+let seriesSourcesLoaded = false;
+let authRetryAttempts = 0;
+
+// Initialize app
+document.addEventListener('DOMContentLoaded', function() {
+ initializeTabs();
+ initializeEventListeners();
+ checkAuthStatus(); // Check authentication status on page load
+ loadDashboard();
+});
+
+// Tab management
+function initializeTabs() {
+ const tabButtons = document.querySelectorAll('.nav-tab');
+ const tabContents = document.querySelectorAll('.tab-content');
+
+ tabButtons.forEach(button => {
+ button.addEventListener('click', function() {
+ const tabName = this.dataset.tab;
+ switchTab(tabName);
+ });
+ });
+}
+
+function switchTab(tabName) {
+ // Update button states
+ document.querySelectorAll('.nav-tab').forEach(btn => btn.classList.remove('active'));
+ document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
+
+ // Update content
+ document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
+ document.getElementById(tabName).classList.add('active');
+
+ currentTab = tabName;
+
+ // Load tab-specific data
+ switch(tabName) {
+ case 'dashboard':
+ loadDashboard();
+ break;
+ case 'movies':
+ loadMovies();
+ break;
+ case 'tv':
+ loadSeries();
+ if (!seriesSourcesLoaded) {
+ loadSeriesSources();
+ }
+ break;
+ case 'reports':
+ loadReport();
+ break;
+ case 'tools':
+ loadDetailedStats();
+ break;
+ }
+}
+
+// Event listeners
+function initializeEventListeners() {
+ // Search inputs
+ document.getElementById('movies-search').addEventListener('input', debounce(loadMovies, 500));
+ document.getElementById('movies-imdb-search').addEventListener('input', debounce(loadMovies, 500));
+ document.getElementById('series-search').addEventListener('input', debounce(loadSeries, 500));
+ document.getElementById('series-imdb-search').addEventListener('input', debounce(loadSeries, 500));
+
+ // Filter dropdowns
+ document.getElementById('movies-filter-date').addEventListener('change', loadMovies);
+ document.getElementById('movies-filter-source').addEventListener('change', loadMovies);
+ document.getElementById('series-filter-date').addEventListener('change', loadSeries);
+ document.getElementById('series-filter-source').addEventListener('change', loadSeries);
+
+ // Forms
+ document.getElementById('edit-form').addEventListener('submit', handleEditSubmit);
+
+ // Manual scan forms
+ document.getElementById('manual-scan-form').addEventListener('submit', handleManualScan);
+ document.getElementById('custom-scan-form').addEventListener('submit', handleCustomScan);
+
+ // Custom directory input auto-formatting
+ document.getElementById('scan-path').addEventListener('input', handleDirectoryFormatting);
+}
+
+// API calls
+async function apiCall(endpoint, options = {}) {
+ try {
+ const response = await fetch(endpoint, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ },
+ ...options
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('API call failed:', error);
+ showToast(`API Error: ${error.message}`, 'error');
+ throw error;
+ }
+}
+
+// Dashboard
+async function loadDashboard() {
+ try {
+ dashboardData = await apiCall('/api/dashboard');
+ updateDashboardStats();
+ updateDashboardCharts();
+
+ // Check if there's an ongoing scan when dashboard loads
+ await checkScanStatus();
+ } catch (error) {
+ console.error('Failed to load dashboard:', error);
+ }
+}
+
+function updateDashboardStats() {
+ if (!dashboardData) return;
+
+ const moviesTotal = dashboardData.movies_total || 0;
+ const moviesWithDates = dashboardData.movies_with_dates || 0;
+ const moviesWithoutDates = dashboardData.movies_without_dates || (moviesTotal - moviesWithDates);
+
+ const episodesTotal = dashboardData.episodes_total || 0;
+ const episodesWithDates = dashboardData.episodes_with_dates || 0;
+ const episodesWithoutDates = dashboardData.episodes_without_dates || (episodesTotal - episodesWithDates);
+
+ document.getElementById('movies-total').textContent = moviesTotal;
+ document.getElementById('movies-with-dates').textContent = `${moviesWithDates} with dates, ${moviesWithoutDates} without`;
+
+ document.getElementById('series-total').textContent = dashboardData.series_count || 0;
+ document.getElementById('episodes-total').textContent = `${episodesTotal} episodes (${episodesWithDates} with dates, ${episodesWithoutDates} without)`;
+
+ const missingTotal = moviesWithoutDates + episodesWithoutDates;
+ document.getElementById('missing-dates-total').textContent = missingTotal;
+
+ const noValidTotal = (dashboardData.movies_no_valid_source || 0) + (dashboardData.episodes_no_valid_source || 0);
+ document.getElementById('no-valid-source-total').textContent = `${moviesWithoutDates} movies, ${episodesWithoutDates} episodes without dates`;
+
+ document.getElementById('recent-activity').textContent = dashboardData.recent_activity_count || 0;
+}
+
+function updateDashboardCharts() {
+ if (!dashboardData) return;
+
+ // Movie sources chart
+ const movieChart = document.getElementById('movie-sources-chart');
+ if (dashboardData.movie_sources && dashboardData.movie_sources.length > 0) {
+ movieChart.innerHTML = createSimpleChart(dashboardData.movie_sources);
+ } else {
+ movieChart.innerHTML = 'No movie source data available
';
+ }
+
+ // Episode sources chart
+ const episodeChart = document.getElementById('episode-sources-chart');
+ if (dashboardData.episode_sources && dashboardData.episode_sources.length > 0) {
+ episodeChart.innerHTML = createSimpleChart(dashboardData.episode_sources);
+ } else {
+ episodeChart.innerHTML = 'No episode source data available
';
+ }
+}
+
+function createSimpleChart(data) {
+ const total = data.reduce((sum, item) => sum + item.count, 0);
+ let html = '';
+
+ data.forEach((item, index) => {
+ const percentage = ((item.count / total) * 100).toFixed(1);
+ const color = getChartColor(index);
+ html += `
+
+ ${item.source}
+ ${item.count} (${percentage}%)
+
+ `;
+ });
+
+ html += '
';
+ return html;
+}
+
+function getChartColor(index) {
+ const colors = ['#007bff', '#28a745', '#ffc107', '#dc3545', '#6c757d', '#17a2b8', '#6f42c1'];
+ return colors[index % colors.length];
+}
+
+// Movies
+async function loadMovies(page = 1) {
+ // Ensure page is a valid number
+ if (isNaN(page) || page < 1) {
+ page = 1;
+ }
+
+ const search = document.getElementById('movies-search').value;
+ const imdbSearch = document.getElementById('movies-imdb-search').value;
+ const hasDate = document.getElementById('movies-filter-date').value;
+ const sourceFilter = document.getElementById('movies-filter-source').value;
+
+ const skip = (page - 1) * 100;
+
+ const params = new URLSearchParams({
+ skip: skip,
+ limit: 100
+ });
+
+ if (search) params.append('search', search);
+ if (imdbSearch) params.append('imdb_search', imdbSearch);
+ if (hasDate) params.append('has_date', hasDate);
+ if (sourceFilter) params.append('source_filter', sourceFilter);
+
+ try {
+ const data = await apiCall(`/api/movies?${params}`);
+ updateMoviesTable(data);
+ updateMoviesPagination(data);
+ updateMoviesSourceFilter(data);
+ currentMoviesPage = (isNaN(page) || page < 1) ? 1 : page;
+ } catch (error) {
+ console.error('Failed to load movies:', error);
+ }
+}
+
+function updateMoviesTable(data) {
+ const tbody = document.getElementById('movies-tbody');
+
+ if (!data.movies || data.movies.length === 0) {
+ tbody.innerHTML = '| No movies found |
';
+ return;
+ }
+
+ tbody.innerHTML = data.movies.map(movie => {
+ const dateadded = movie.dateadded ? formatDateTime(movie.dateadded) : '';
+ const hasVideoBadge = movie.has_video_file ?
+ 'Yes' :
+ 'No';
+
+ // Determine date type based on source and dates
+ let dateType = 'Unknown';
+ let dateTypeBadge = 'badge-secondary';
+
+ if (movie.source === 'digital_release') {
+ dateType = 'Digital Release';
+ dateTypeBadge = 'badge-success';
+ } else if (movie.source && movie.source.includes('radarr') && movie.source.includes('import')) {
+ dateType = 'Radarr Import';
+ dateTypeBadge = 'badge-warning';
+ } else if (movie.source === 'manual') {
+ dateType = 'Manual';
+ dateTypeBadge = 'badge-info';
+ } else if (movie.source === 'nfo_file_existing') {
+ dateType = 'Existing NFO';
+ dateTypeBadge = 'badge-secondary';
+ } else if (movie.source === 'no_valid_date_source') {
+ dateType = 'No Valid Source';
+ dateTypeBadge = 'badge-danger';
+ } else if (movie.source && movie.source.toLowerCase().includes('tmdb:theatrical')) {
+ dateType = 'TMDB Theatrical';
+ dateTypeBadge = 'badge-primary';
+ } else if (movie.source && movie.source.toLowerCase().includes('tmdb:digital')) {
+ dateType = 'TMDB Digital';
+ dateTypeBadge = 'badge-primary';
+ } else if (movie.source && movie.source.toLowerCase().includes('tmdb:physical')) {
+ dateType = 'TMDB Physical';
+ dateTypeBadge = 'badge-primary';
+ } else if (movie.source && movie.source.toLowerCase().includes('tmdb:')) {
+ dateType = 'TMDB Release';
+ dateTypeBadge = 'badge-primary';
+ } else if (movie.source && movie.source.toLowerCase().includes('omdb:')) {
+ dateType = 'OMDb Release';
+ dateTypeBadge = 'badge-info';
+ } else if (movie.source && movie.source.toLowerCase().includes('webhook:')) {
+ dateType = 'Webhook/API';
+ dateTypeBadge = 'badge-warning';
+ }
+
+ return `
+
+ | ${escapeHtml(movie.title)} |
+ ${movie.imdb_id} |
+ ${movie.released || '-'} |
+ ${dateadded || '-'} |
+ ${movie.source_description || movie.source || 'Unknown'} |
+ ${dateType} |
+ ${hasVideoBadge} |
+
+
+
+
+ |
+
+ `;
+ }).join('');
+}
+
+function updateMoviesPagination(data) {
+ const pagination = document.getElementById('movies-pagination');
+
+ if (data.pages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let html = '';
+
+ if (data.has_prev) {
+ html += ``;
+ }
+
+ html += `Page ${data.page} of ${data.pages}`;
+
+ if (data.has_next) {
+ html += ``;
+ }
+
+ pagination.innerHTML = html;
+}
+
+function updateMoviesSourceFilter(data) {
+ // This would be populated from dashboard data
+ if (dashboardData && dashboardData.movie_sources) {
+ const select = document.getElementById('movies-filter-source');
+ const currentValue = select.value;
+
+ select.innerHTML = '';
+ dashboardData.movie_sources.forEach(source => {
+ select.innerHTML += ``;
+ });
+
+ select.value = currentValue;
+ }
+}
+
+async function loadSeriesSources() {
+ try {
+ const data = await apiCall('/api/series/sources');
+ const select = document.getElementById('series-filter-source');
+ const currentValue = select.value;
+
+ select.innerHTML = '';
+ data.sources.forEach(source => {
+ select.innerHTML += ``;
+ });
+
+ select.value = currentValue;
+ seriesSourcesLoaded = true;
+ } catch (error) {
+ console.error('Failed to load series sources:', error);
+ }
+}
+
+function refreshMovies() {
+ loadMovies(isNaN(currentMoviesPage) ? 1 : currentMoviesPage);
+}
+
+// TV Series
+async function loadSeries(page = 1) {
+ // Ensure page is a valid number
+ if (isNaN(page) || page < 1) {
+ page = 1;
+ }
+
+ const search = document.getElementById('series-search').value;
+ const imdbSearch = document.getElementById('series-imdb-search').value;
+ const dateFilter = document.getElementById('series-filter-date').value;
+ const sourceFilter = document.getElementById('series-filter-source').value;
+
+ const skip = (page - 1) * 50;
+
+ const params = new URLSearchParams({
+ skip: skip,
+ limit: 50
+ });
+
+ if (search) params.append('search', search);
+ if (imdbSearch) params.append('imdb_search', imdbSearch);
+ if (dateFilter) params.append('date_filter', dateFilter);
+ if (sourceFilter) params.append('source_filter', sourceFilter);
+
+ try {
+ const data = await apiCall(`/api/series?${params}`);
+ updateSeriesTable(data);
+ updateSeriesPagination(data);
+ currentSeriesPage = (isNaN(page) || page < 1) ? 1 : page;
+ } catch (error) {
+ console.error('Failed to load series:', error);
+ }
+}
+
+function updateSeriesTable(data) {
+ const tbody = document.getElementById('series-tbody');
+
+ if (!data.series || data.series.length === 0) {
+ tbody.innerHTML = '| No series found |
';
+ return;
+ }
+
+ tbody.innerHTML = data.series.map(series => {
+ const progressPercent = series.total_episodes > 0 ?
+ ((series.episodes_with_dates / series.total_episodes) * 100).toFixed(1) : 0;
+
+ return `
+
+ | ${escapeHtml(series.title)} |
+ ${series.imdb_id} |
+ ${series.total_episodes} |
+
+ ${series.episodes_with_dates}
+ (${progressPercent}%)
+ |
+ ${series.episodes_with_video} |
+
+
+ |
+
+ `;
+ }).join('');
+}
+
+function updateSeriesPagination(data) {
+ const pagination = document.getElementById('series-pagination');
+
+ if (data.pages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let html = '';
+
+ if (data.has_prev) {
+ html += ``;
+ }
+
+ html += `Page ${data.page} of ${data.pages}`;
+
+ if (data.has_next) {
+ html += ``;
+ }
+
+ pagination.innerHTML = html;
+}
+
+function refreshSeries() {
+ loadSeries(isNaN(currentSeriesPage) ? 1 : currentSeriesPage);
+}
+
+async function viewSeriesEpisodes(imdbId) {
+ try {
+ const data = await apiCall(`/api/series/${imdbId}/episodes`);
+ showEpisodesModal(data);
+ } catch (error) {
+ console.error('Failed to load episodes:', error);
+ }
+}
+
+function showEpisodesModal(data) {
+ // Calculate statistics
+ const totalEpisodes = data.episodes.length;
+ const episodesWithDates = data.episodes.filter(ep => ep.dateadded && ep.dateadded.trim() !== '').length;
+ const episodesWithoutDates = totalEpisodes - episodesWithDates;
+ const episodesWithVideo = data.episodes.filter(ep => ep.has_video_file).length;
+
+ const modalHtml = `
+
+
+
+
+
+
Total Episodes: ${totalEpisodes}
+
With Dates: ${episodesWithDates}
+
Missing Dates: ${episodesWithoutDates}
+
With Video: ${episodesWithVideo}
+
+
+
+
+
+
+
+
+
+
+
+
+ | Episode |
+ Aired |
+ Date Added |
+ Source |
+ Video |
+ Actions |
+
+
+
+ ${data.episodes.map(episode => {
+ const dateadded = episode.dateadded ? formatDateTime(episode.dateadded) : '';
+ const hasVideoBadge = episode.has_video_file ?
+ 'Yes' :
+ 'No';
+
+ const missingDate = !episode.dateadded || episode.dateadded.trim() === '';
+ const rowClass = missingDate ? 'missing-date-row' : '';
+ const dateCell = missingDate ?
+ 'MISSING | ' :
+ `${dateadded} | `;
+
+ return `
+
+ | S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')} |
+ ${episode.aired || '-'} |
+ ${dateCell}
+ ${episode.source_description || episode.source || 'Unknown'} |
+ ${hasVideoBadge} |
+
+
+
+ |
+
+ `;
+ }).join('')}
+
+
+
+
+
+
+ `;
+
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+}
+
+function filterEpisodes(filterType) {
+ const rows = document.querySelectorAll('#episodes-table-body tr');
+
+ rows.forEach(row => {
+ const hasDate = row.getAttribute('data-has-date') === 'true';
+ let shouldShow = true;
+
+ switch (filterType) {
+ case 'missing':
+ shouldShow = !hasDate;
+ break;
+ case 'has-dates':
+ shouldShow = hasDate;
+ break;
+ case 'all':
+ default:
+ shouldShow = true;
+ break;
+ }
+
+ row.style.display = shouldShow ? '' : 'none';
+ });
+}
+
+function closeEpisodesModal() {
+ const modal = document.getElementById('episodes-modal');
+ if (modal) {
+ modal.remove();
+ }
+}
+
+// Reports
+async function loadReport() {
+ try {
+ const data = await apiCall('/api/reports/missing-dates');
+ updateReportSummary(data.summary);
+ updateReportTables(data);
+ } catch (error) {
+ console.error('Failed to load report:', error);
+ }
+}
+
+function updateReportSummary(summary) {
+ document.getElementById('report-movies-with').textContent = summary.movies_with_dates;
+ document.getElementById('report-movies-missing').textContent = summary.movies_missing_dates;
+ document.getElementById('report-episodes-with').textContent = summary.episodes_with_dates;
+ document.getElementById('report-episodes-missing').textContent = summary.episodes_missing_dates;
+}
+
+function updateReportTables(data) {
+ // Movies missing dates
+ const moviesTbody = document.getElementById('report-movies-tbody');
+ if (data.movies_missing.length === 0) {
+ moviesTbody.innerHTML = '| No movies missing dates |
';
+ } else {
+ moviesTbody.innerHTML = data.movies_missing.map(movie => `
+
+ | ${escapeHtml(movie.title)} |
+ ${movie.imdb_id} |
+ ${movie.released || '-'} |
+ ${movie.source_description || movie.source || 'Unknown'} |
+
+
+ |
+
+ `).join('');
+ }
+
+ // Episodes missing dates
+ const episodesTbody = document.getElementById('report-episodes-tbody');
+ if (data.episodes_missing.length === 0) {
+ episodesTbody.innerHTML = '| No episodes missing dates |
';
+ } else {
+ episodesTbody.innerHTML = data.episodes_missing.map(episode => `
+
+ | ${escapeHtml(episode.series_title)} |
+ S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')} |
+ ${episode.imdb_id} |
+ ${episode.aired || '-'} |
+ ${episode.source_description || episode.source || 'Unknown'} |
+
+
+ |
+
+ `).join('');
+ }
+}
+
+function refreshReport() {
+ loadReport();
+}
+
+// Smart fix functions
+async function smartFixMovie(imdbId) {
+ try {
+ console.log('๐ SMART FIX: Loading options for movie', imdbId);
+ const options = await apiCall(`/api/movies/${imdbId}/date-options`);
+ console.log('๐ SMART FIX: Received options:', options);
+ showSmartFixModal('movie', options);
+ } catch (error) {
+ console.error('Failed to load movie options:', error);
+ showToast('Failed to load movie options', 'error');
+ }
+}
+
+async function smartFixEpisode(imdbId, season, episode) {
+ try {
+ const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
+ showSmartFixModal('episode', options);
+ } catch (error) {
+ console.error('Failed to load episode options:', error);
+ showToast('Failed to load episode options', 'error');
+ }
+}
+
+function showSmartFixModal(mediaType, options) {
+ console.log('๐ SMART FIX: Showing modal for', mediaType, 'with options:', options);
+
+ const modal = document.getElementById('smart-fix-modal');
+ const title = document.getElementById('smart-fix-title');
+ const content = document.getElementById('smart-fix-content');
+
+ if (!modal || !title || !content) {
+ console.error('โ SMART FIX: Modal elements not found!', {modal, title, content});
+ alert('Smart Fix modal not found - check console for details');
+ return;
+ }
+
+ console.log('โ
SMART FIX: Modal elements found, proceeding to show Smart Fix modal');
+
+ if (mediaType === 'movie') {
+ title.textContent = `Fix Date for Movie: ${options.imdb_id}`;
+ } else {
+ title.textContent = `Fix Date for Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`;
+ }
+
+ // Build options HTML
+ let optionsHtml = '';
+
+ options.options.forEach((option, index) => {
+ const isChecked = index === 0 ? 'checked' : '';
+ const dateInput = option.type === 'manual' ?
+ `
` : '';
+
+ optionsHtml += `
+
+
+
+ `;
+ });
+
+ optionsHtml += '
';
+
+ optionsHtml += `
+
+
+
+
+ `;
+
+ content.innerHTML = optionsHtml;
+ modal.classList.add('active');
+}
+
+function closeSmartFixModal() {
+ document.getElementById('smart-fix-modal').classList.remove('active');
+}
+
+async function applySmartFix(mediaType, options) {
+ const selectedRadio = document.querySelector('input[name="date-option"]:checked');
+ if (!selectedRadio) {
+ showToast('Please select a date option', 'warning');
+ return;
+ }
+
+ const selectedIndex = selectedRadio.value;
+ const selectedOption = options.options[selectedIndex];
+
+ let dateadded = selectedOption.date;
+ let source = selectedOption.source;
+
+ // Handle manual date entry
+ if (selectedOption.type === 'manual') {
+ const manualDateInput = document.getElementById(`manual-date-${selectedIndex}`);
+ if (manualDateInput && manualDateInput.value) {
+ try {
+ dateadded = new Date(manualDateInput.value).toISOString();
+ } catch (e) {
+ showToast('Invalid date format', 'error');
+ return;
+ }
+ } else {
+ showToast('Please enter a date for manual option', 'warning');
+ return;
+ }
+ } else if (dateadded) {
+ // Fix date format for non-manual options
+ try {
+ let dateValue = dateadded;
+
+ // Handle timezone offsets
+ if (dateValue.includes('+00:00')) {
+ dateValue = dateValue.replace('+00:00', 'Z');
+ }
+
+ const date = new Date(dateValue);
+ if (isNaN(date.getTime())) {
+ showToast('Invalid date from server', 'error');
+ return;
+ }
+ dateadded = date.toISOString();
+ } catch (e) {
+ console.error('Date conversion error:', e, dateadded);
+ showToast('Date conversion error', 'error');
+ return;
+ }
+ }
+
+ // Debug logging
+ console.log('๐ SMART FIX DEBUG:', {
+ mediaType,
+ imdb_id: options.imdb_id,
+ selectedOption,
+ dateadded,
+ source,
+ originalDate: selectedOption.date
+ });
+
+ try {
+ if (mediaType === 'movie') {
+ await updateMovieDate(options.imdb_id, dateadded, source);
+ } else {
+ await updateEpisodeDate(options.imdb_id, options.season, options.episode, dateadded, source);
+ }
+ closeSmartFixModal();
+ } catch (error) {
+ console.error('Smart fix failed:', error);
+ showToast('Smart fix failed: ' + error.message, 'error');
+ }
+}
+
+// Tools
+async function loadDetailedStats() {
+ try {
+ const data = await apiCall('/api/dashboard');
+ const statsHtml = `
+
+
+ Database Size: ${data.database_size_mb} MB
+
+
+ Total Movies: ${data.movies_total} (${data.movies_with_video} with video files)
+
+
+ Movies with Dates: ${data.movies_with_dates} (${((data.movies_with_dates / data.movies_total) * 100).toFixed(1)}%)
+
+
+ Total Series: ${data.series_count}
+
+
+ Total Episodes: ${data.episodes_total} (${data.episodes_with_video} with video files)
+
+
+ Episodes with Dates: ${data.episodes_with_dates} (${((data.episodes_with_dates / data.episodes_total) * 100).toFixed(1)}%)
+
+
+ Processing History: ${data.processing_history_count} events
+
+
+ `;
+ document.getElementById('detailed-stats').innerHTML = statsHtml;
+ } catch (error) {
+ console.error('Failed to load detailed stats:', error);
+ }
+}
+
+
+// Edit modal functions
+async function editMovie(imdbId, dateadded, source) {
+ try {
+ // Load movie options to populate available dates
+ const options = await apiCall(`/api/movies/${imdbId}/date-options`);
+ showEnhancedEditModal('movie', options, dateadded, source);
+ } catch (error) {
+ console.error('Failed to load movie options for edit:', error);
+ // Fallback to basic edit modal
+ showBasicEditModal('movie', imdbId, dateadded, source);
+ }
+}
+
+function showEnhancedEditModal(mediaType, options, currentDateadded, currentSource) {
+ const modal = document.getElementById('edit-modal');
+ const title = document.getElementById('modal-title');
+ const modalBody = document.querySelector('#edit-modal .modal-body');
+
+ if (mediaType === 'movie') {
+ title.textContent = `Edit Movie: ${options.imdb_id}`;
+ } else {
+ title.textContent = `Edit Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`;
+ }
+
+ // Build enhanced edit form with date options
+ let formHtml = `
+
+
+ ${mediaType === 'episode' ? `
+
+
+ ` : `
+
+
+ `}
+
+
+
+
+
+
+ Adjust the date/time as needed
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ modalBody.innerHTML = formHtml;
+
+
+ // Set current values
+ if (currentDateadded && currentDateadded !== '-') {
+ try {
+ const date = new Date(currentDateadded);
+ document.getElementById('edit-dateadded').value = date.toISOString().slice(0, 16);
+ } catch (e) {
+ document.getElementById('edit-dateadded').value = '';
+ }
+ }
+
+ document.getElementById('edit-source').value = currentSource || 'manual';
+
+ // Store options for later use
+ modal.dataset.options = JSON.stringify(options);
+
+ modal.classList.add('active');
+}
+
+function showBasicEditModal(mediaType, imdbId, dateadded, source) {
+ // Fallback to original basic edit modal
+ document.getElementById('modal-title').textContent = `Edit ${mediaType}: ${imdbId}`;
+ document.getElementById('edit-imdb-id').value = imdbId;
+ document.getElementById('edit-media-type').value = mediaType;
+
+ if (dateadded && dateadded !== '-') {
+ try {
+ const date = new Date(dateadded);
+ document.getElementById('edit-dateadded').value = date.toISOString().slice(0, 16);
+ } catch (e) {
+ document.getElementById('edit-dateadded').value = '';
+ }
+ } else {
+ document.getElementById('edit-dateadded').value = '';
+ }
+
+ document.getElementById('edit-source').value = source || 'manual';
+ document.getElementById('edit-modal').classList.add('active');
+}
+
+function updateEditDateFromOption(optionIndex, option) {
+ const dateInput = document.getElementById('edit-dateadded');
+ const sourceSelect = document.getElementById('edit-source');
+
+ if (option.date) {
+ // Convert to datetime-local format with better date parsing
+ try {
+ let dateValue = option.date;
+
+ // Handle timezone offsets by converting to local time
+ if (dateValue.includes('+00:00') || dateValue.includes('Z')) {
+ dateValue = dateValue.replace('+00:00', 'Z');
+ }
+
+ const date = new Date(dateValue);
+ if (isNaN(date.getTime())) {
+ console.error('Invalid date:', dateValue);
+ dateInput.value = '';
+ } else {
+ // Convert to local datetime-local format
+ const localDateTime = new Date(date.getTime() - (date.getTimezoneOffset() * 60000));
+ dateInput.value = localDateTime.toISOString().slice(0, 16);
+ }
+ } catch (e) {
+ console.error('Date parsing error:', e, option.date);
+ dateInput.value = '';
+ }
+ } else {
+ // Manual option - clear the date for user input
+ dateInput.value = '';
+ }
+
+ sourceSelect.value = option.source;
+}
+
+async function handleEnhancedEditSubmit(event) {
+ event.preventDefault();
+
+ const modal = document.getElementById('edit-modal');
+ const options = JSON.parse(modal.dataset.options);
+ const imdbId = options.imdb_id;
+ const mediaType = document.getElementById('edit-media-type').value;
+ const dateadded = document.getElementById('edit-dateadded').value;
+ const source = document.getElementById('edit-source').value;
+
+ if (!dateadded) {
+ showToast('Please enter a date', 'warning');
+ return;
+ }
+
+ // Convert datetime-local to ISO string with error handling
+ let isoDateadded = null;
+ try {
+ isoDateadded = new Date(dateadded).toISOString();
+ } catch (e) {
+ showToast('Invalid date format', 'error');
+ return;
+ }
+
+ try {
+ if (mediaType === 'movie') {
+ await updateMovieDate(imdbId, isoDateadded, source);
+ } else {
+ await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source);
+ }
+
+ closeModal();
+ } catch (error) {
+ console.error('Enhanced edit failed:', error);
+ showToast('Update failed: ' + error.message, 'error');
+ }
+}
+
+async function editEpisode(imdbId, season, episode, dateadded, source) {
+ try {
+ // Load episode options to populate available dates
+ const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`);
+ showEnhancedEditModal('episode', options, dateadded, source);
+ } catch (error) {
+ console.error('Failed to load episode options for edit:', error);
+ // Fallback to basic edit modal
+ showBasicEditModal('episode', imdbId, dateadded, source, season, episode);
+ }
+}
+
+function closeModal() {
+ document.getElementById('edit-modal').classList.remove('active');
+}
+
+async function handleEditSubmit(event) {
+ event.preventDefault();
+
+ const imdbId = document.getElementById('edit-imdb-id').value;
+ const mediaType = document.getElementById('edit-media-type').value;
+ const season = document.getElementById('edit-season').value;
+ const episode = document.getElementById('edit-episode').value;
+ const dateadded = document.getElementById('edit-dateadded').value;
+ const source = document.getElementById('edit-source').value;
+
+ // Convert datetime-local to ISO string
+ const isoDateadded = dateadded ? new Date(dateadded).toISOString() : null;
+
+ try {
+ if (mediaType === 'movie') {
+ await updateMovieDate(imdbId, isoDateadded, source);
+ } else {
+ await updateEpisodeDate(imdbId, parseInt(season), parseInt(episode), isoDateadded, source);
+ }
+
+ closeModal();
+ } catch (error) {
+ console.error('Update failed:', error);
+ }
+}
+
+// Update functions
+async function updateMovieDate(imdbId, dateadded, source) {
+ try {
+ const result = await apiCall(`/api/movies/${imdbId}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ dateadded: dateadded,
+ source: source
+ })
+ });
+
+ showToast(result.message, 'success');
+
+ // Refresh current view
+ if (currentTab === 'movies') loadMovies(currentMoviesPage);
+ if (currentTab === 'reports') loadReport();
+ if (currentTab === 'dashboard') loadDashboard();
+
+ } catch (error) {
+ console.error('Movie update failed:', error);
+ }
+}
+
+async function updateEpisodeDate(imdbId, season, episode, dateadded, source) {
+ try {
+ const result = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ dateadded: dateadded,
+ source: source
+ })
+ });
+
+ showToast(result.message, 'success');
+
+ // Refresh current view
+ if (currentTab === 'tv') loadSeries(currentSeriesPage);
+ if (currentTab === 'reports') loadReport();
+ if (currentTab === 'dashboard') loadDashboard();
+
+ // Refresh episodes modal if open
+ const episodesModal = document.getElementById('episodes-modal');
+ if (episodesModal) {
+ closeEpisodesModal();
+ setTimeout(() => viewSeriesEpisodes(imdbId), 100);
+ }
+
+ } catch (error) {
+ console.error('Episode update failed:', error);
+ }
+}
+
+// Utility functions
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
+function formatDateTime(dateString) {
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleString();
+ } catch (e) {
+ return dateString;
+ }
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function showToast(message, type = 'info') {
+ const container = document.getElementById('toast-container');
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.innerHTML = `
+
+ ${escapeHtml(message)}
+
+ `;
+
+ container.appendChild(toast);
+
+ // Auto remove after 5 seconds
+ setTimeout(() => {
+ if (toast.parentNode) {
+ toast.parentNode.removeChild(toast);
+ }
+ }, 5000);
+
+ // Remove on click
+ toast.addEventListener('click', () => {
+ if (toast.parentNode) {
+ toast.parentNode.removeChild(toast);
+ }
+ });
+}
+
+// Debug function
+async function debugMovie(imdbId) {
+ try {
+ const data = await apiCall(`/api/debug/movie/${imdbId}/raw`);
+
+ const debugInfo = `
+DEBUG INFO for ${imdbId}:
+
+Raw Database Data:
+- imdb_id: ${data.raw_data.imdb_id}
+- path: ${data.raw_data.path}
+- released: ${data.raw_data.released}
+- dateadded: ${data.raw_data.dateadded}
+- source: ${data.raw_data.source}
+- has_video_file: ${data.raw_data.has_video_file}
+- last_updated: ${data.raw_data.last_updated}
+
+Analysis:
+- Movie Released: ${data.raw_data.released || 'Not set'}
+- Library Import Date: ${data.raw_data.dateadded || 'Not set'}
+- Date Source: ${data.raw_data.source_description || data.raw_data.source || 'Unknown'}
+ `;
+
+ alert(debugInfo);
+ console.log('๐ Debug data for', imdbId, data);
+
+ } catch (error) {
+ console.error('Debug failed:', error);
+ showToast('Debug failed: ' + error.message, 'error');
+ }
+}
+
+// Episode deletion functionality
+async function deleteEpisode(imdbId, season, episode) {
+ const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`;
+
+ // Confirmation dialog
+ if (!confirm(`โ ๏ธ Delete Episode ${episodeStr}?\n\nThis will permanently remove the episode from the database.\n\nAre you sure you want to continue?`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.success) {
+ showToast(`โ
Episode ${episodeStr} deleted successfully`, 'success');
+
+ // Remove the row from the table
+ const rows = document.querySelectorAll('#episodes-table-body tr');
+ rows.forEach(row => {
+ const episodeCell = row.querySelector('td:first-child');
+ if (episodeCell && episodeCell.textContent === episodeStr) {
+ row.remove();
+ }
+ });
+
+ // Update episode counts in modal header
+ updateEpisodeModalCounts();
+
+ } else {
+ const errorMsg = result.message || result.error || 'Unknown error';
+ showToast(`โ Failed to delete episode: ${errorMsg}`, 'error');
+ }
+
+ } catch (error) {
+ console.error('Delete episode failed:', error);
+ showToast(`โ Delete failed: ${error.message}`, 'error');
+ }
+}
+
+// Movie deletion functionality
+async function deleteMovie(imdbId) {
+ // Confirmation dialog
+ if (!confirm(`โ ๏ธ Delete Movie?\n\nThis will permanently remove the movie (${imdbId}) from the database.\n\nAre you sure you want to continue?`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/database/movie/${imdbId}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const result = await response.json();
+
+ if (response.ok && result.success) {
+ showToast(`โ
Movie deleted successfully`, 'success');
+
+ // Refresh the movies table
+ loadMovies(currentMoviesPage);
+
+ } else {
+ const errorMsg = result.message || result.error || 'Unknown error';
+ showToast(`โ Failed to delete movie: ${errorMsg}`, 'error');
+ }
+
+ } catch (error) {
+ console.error('Delete movie failed:', error);
+ showToast(`โ Delete failed: ${error.message}`, 'error');
+ }
+}
+
+// Update episode counts in modal after deletion
+function updateEpisodeModalCounts() {
+ const remainingRows = document.querySelectorAll('#episodes-table-body tr');
+ const totalEpisodes = remainingRows.length;
+ const episodesWithDates = Array.from(remainingRows).filter(row =>
+ row.getAttribute('data-has-date') === 'true'
+ ).length;
+ const episodesWithoutDates = totalEpisodes - episodesWithDates;
+
+ // Update the stats in the modal
+ const statsDiv = document.querySelector('.episode-stats');
+ if (statsDiv) {
+ // Keep the existing "With Video" count by finding it
+ const videoCountDiv = statsDiv.querySelector('div:nth-child(4)');
+ const videoCountText = videoCountDiv ? videoCountDiv.innerHTML : 'With Video: -
';
+
+ statsDiv.innerHTML = `
+ Total Episodes: ${totalEpisodes}
+ With Dates: ${episodesWithDates}
+ Missing Dates: ${episodesWithoutDates}
+ ${videoCountText}
+ `;
+ }
+}
+
+// ===========================
+// Authentication Functions
+// ===========================
+
+async function checkAuthStatus() {
+ try {
+ const response = await fetch('/api/auth/status');
+ const authStatus = await response.json();
+
+ const authStatusDiv = document.getElementById('auth-status');
+ const authUsernameSpan = document.getElementById('auth-username');
+
+ if (authStatus.auth_enabled && authStatus.authenticated) {
+ // Show authentication status with username
+ authUsernameSpan.textContent = authStatus.username;
+ authStatusDiv.style.display = 'flex';
+ } else if (authStatus.auth_enabled && !authStatus.authenticated) {
+ // This can happen briefly during page load - retry once after a short delay
+ if (authRetryAttempts < 1) {
+ authRetryAttempts++;
+ console.debug('Auth enabled but not authenticated - retrying auth check in 500ms');
+ setTimeout(() => {
+ checkAuthStatus();
+ }, 500);
+ } else {
+ console.warn('Auth enabled but not authenticated after retry - middleware may be misconfigured');
+ }
+ } else {
+ // Authentication disabled - hide auth status
+ authStatusDiv.style.display = 'none';
+ }
+
+ } catch (error) {
+ console.error('Failed to check authentication status:', error);
+ // Hide auth status on error
+ document.getElementById('auth-status').style.display = 'none';
+ }
+}
+
+async function logout() {
+ if (!confirm('Are you sure you want to logout?')) {
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/auth/logout', {
+ method: 'POST',
+ credentials: 'same-origin'
+ });
+
+ if (response.ok) {
+ showToast('โ
Logged out successfully', 'success');
+ // Reload page to trigger authentication prompt
+ setTimeout(() => {
+ window.location.reload();
+ }, 1500);
+ } else {
+ showToast('โ Logout failed', 'error');
+ }
+
+ } catch (error) {
+ console.error('Logout failed:', error);
+ showToast('โ Logout error', 'error');
+ }
+}
+
+// ===========================
+// Manual Scan Functions
+// ===========================
+
+async function handleManualScan(event) {
+ event.preventDefault();
+
+ const scanType = document.getElementById('scan-type').value;
+ const scanMode = document.getElementById('scan-mode').value;
+
+ if (!scanType || !scanMode) {
+ showToast('Please select both scan type and mode', 'warning');
+ return;
+ }
+
+ try {
+ showToast('๐ Starting manual scan...', 'info');
+ updateScanStatus('Starting manual scan...', true);
+
+ // Send as query parameters instead of JSON body
+ const params = new URLSearchParams({
+ scan_type: scanType,
+ scan_mode: scanMode
+ });
+
+ const result = await apiCall(`/manual/scan?${params}`, {
+ method: 'POST'
+ });
+
+ showToast(`โ
Manual scan initiated: ${result.message}`, 'success');
+
+ // Track scan start time for status monitoring
+ try {
+ await apiCall('/api/scan/track', { method: 'POST' });
+ } catch (e) {
+ console.log('Failed to track scan start:', e);
+ }
+
+ // Start polling for scan status
+ startScanStatusPolling();
+
+ } catch (error) {
+ console.error('Manual scan failed:', error);
+ showToast(`โ Manual scan failed: ${error.message}`, 'error');
+ updateScanStatus('Scan failed', false);
+ }
+}
+
+async function handleCustomScan(event) {
+ event.preventDefault();
+
+ const customDirectory = document.getElementById('scan-path').value.trim();
+ const customScanType = document.getElementById('custom-scan-type').value;
+
+ if (!customDirectory) {
+ showToast('Please enter a directory path', 'warning');
+ return;
+ }
+
+ if (!customScanType) {
+ showToast('Please select a scan type', 'warning');
+ return;
+ }
+
+ try {
+ showToast('๐ Starting custom directory scan...', 'info');
+ updateScanStatus('Starting custom directory scan...', true);
+
+ // Send as query parameters instead of JSON body
+ const params = new URLSearchParams({
+ path: customDirectory,
+ scan_type: customScanType,
+ scan_mode: 'smart' // Default to smart mode for custom scans
+ });
+
+ const result = await apiCall(`/manual/scan?${params}`, {
+ method: 'POST'
+ });
+
+ showToast(`โ
Custom scan initiated: ${result.message}`, 'success');
+
+ // Track scan start time for status monitoring
+ try {
+ await apiCall('/api/scan/track', { method: 'POST' });
+ } catch (e) {
+ console.log('Failed to track scan start:', e);
+ }
+
+ // Start polling for scan status
+ startScanStatusPolling();
+
+ } catch (error) {
+ console.error('Custom scan failed:', error);
+ showToast(`โ Custom scan failed: ${error.message}`, 'error');
+ updateScanStatus('Scan failed', false);
+ }
+}
+
+function handleDirectoryFormatting(event) {
+ const input = event.target;
+ let value = input.value;
+
+ // Auto-format common directory patterns
+ if (value && !value.startsWith('/')) {
+ // Add leading slash for absolute paths
+ value = '/' + value;
+ input.value = value;
+ }
+
+ // Remove multiple consecutive slashes
+ value = value.replace(/\/+/g, '/');
+
+ // Remove trailing slash unless it's just '/'
+ if (value.length > 1 && value.endsWith('/')) {
+ value = value.slice(0, -1);
+ }
+
+ input.value = value;
+}
+
+// ===========================
+// Scan Status Functions
+// ===========================
+
+let scanStatusInterval = null;
+
+function updateScanStatus(message, isActive = false) {
+ const statusBanner = document.getElementById('dashboard-scan-status');
+ const statusText = document.getElementById('dashboard-scan-text');
+
+ if (!statusBanner || !statusText) {
+ return;
+ }
+
+ statusText.textContent = message;
+
+ if (isActive) {
+ statusBanner.className = 'scan-status-banner active';
+ statusBanner.style.display = 'block';
+ } else {
+ statusBanner.className = 'scan-status-banner';
+ // Don't hide completely, just mark as inactive
+ setTimeout(() => {
+ if (statusBanner.className === 'scan-status-banner') {
+ statusBanner.style.display = 'none';
+ }
+ }, 3000);
+ }
+}
+
+async function checkScanStatus() {
+ try {
+ const status = await apiCall('/api/scan/status');
+
+ if (status.scanning) {
+ // Build detailed status message from core container data
+ let message = status.message || 'Scan in progress...';
+
+ // Add progress details if available
+ if (status.current_operation === 'tv' && status.tv_series_total > 0) {
+ const progress = `${status.tv_series_processed}/${status.tv_series_total}`;
+ message = `Processing TV series (${progress})`;
+ if (status.current_item) {
+ message += ` | Current: ${status.current_item}`;
+ }
+ if (status.elapsed_seconds) {
+ const elapsed = formatElapsedTime(status.elapsed_seconds);
+ message += ` | ${elapsed} elapsed`;
+ }
+ } else if (status.current_operation === 'movies' && status.movies_total > 0) {
+ const progress = `${status.movies_processed}/${status.movies_total}`;
+ message = `Processing movies (${progress})`;
+ if (status.current_item) {
+ message += ` | Current: ${status.current_item}`;
+ }
+ if (status.elapsed_seconds) {
+ const elapsed = formatElapsedTime(status.elapsed_seconds);
+ message += ` | ${elapsed} elapsed`;
+ }
+ } else if (status.elapsed_seconds) {
+ const elapsed = formatElapsedTime(status.elapsed_seconds);
+ message = `Scan in progress | ${elapsed} elapsed`;
+ if (status.current_item) {
+ message += ` | Current: ${status.current_item}`;
+ }
+ }
+
+ updateScanStatus(message, true);
+
+ // Start polling if not already polling
+ if (!scanStatusInterval) {
+ startScanStatusPolling();
+ }
+ return true; // Continue polling
+ } else {
+ updateScanStatus(status.message || 'No scan in progress', false);
+ return false; // Stop polling
+ }
+
+ } catch (error) {
+ console.error('Failed to check scan status:', error);
+ updateScanStatus('Unable to check scan status', false);
+ return false; // Stop polling on error
+ }
+}
+
+function formatElapsedTime(seconds) {
+ if (seconds >= 60) {
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return `${minutes}m ${remainingSeconds}s`;
+ } else {
+ return `${seconds}s`;
+ }
+}
+
+function startScanStatusPolling() {
+ // Don't start if already polling
+ if (scanStatusInterval) {
+ return;
+ }
+
+ // Check status every 2 seconds
+ scanStatusInterval = setInterval(async () => {
+ const shouldContinue = await checkScanStatus();
+
+ if (!shouldContinue) {
+ clearInterval(scanStatusInterval);
+ scanStatusInterval = null;
+
+ // Refresh dashboard data after scan completes
+ if (currentTab === 'dashboard') {
+ setTimeout(loadDashboard, 1000);
+ }
+ }
+ }, 2000);
+
+}
+
+function stopScanStatusPolling() {
+ if (scanStatusInterval) {
+ clearInterval(scanStatusInterval);
+ scanStatusInterval = null;
+ }
+}
\ No newline at end of file
diff --git a/processors/movie_processor.py b/processors/movie_processor.py
index 4b18192..c7a5007 100644
--- a/processors/movie_processor.py
+++ b/processors/movie_processor.py
@@ -387,9 +387,6 @@ class MovieProcessor:
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
- # Yield control briefly during movie processing to allow web interface requests
- import time
- time.sleep(0.005) # 5ms yield per movie to improve responsiveness
# Save to database
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
diff --git a/processors/tv_processor.py b/processors/tv_processor.py
index 051d29e..a3f3ac3 100644
--- a/processors/tv_processor.py
+++ b/processors/tv_processor.py
@@ -214,11 +214,6 @@ class TVProcessor:
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
# Continue processing other episodes
- # Yield control every 3 episodes to allow other requests (webhooks, web interface)
- if episode_count % 3 == 0:
- import time
- time.sleep(0.1) # 100ms yield to improve responsiveness during episode processing
- _log("DEBUG", f"Processed {episode_count} episodes, yielding to allow other requests...")
# Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
pass
@@ -453,11 +448,6 @@ class TVProcessor:
episode_data['dateAdded'] = import_date
_log("DEBUG", f"Got import date from history for S{season:02d}E{episode_num:02d}: {import_date}")
- # Yield control every 5 API calls to allow other requests
- if api_calls_made % 5 == 0:
- import time
- time.sleep(0.01) # 10ms yield to other processes
- _log("DEBUG", f"Yielded after {api_calls_made} Sonarr API calls to allow other requests...")
# Fallback to episodeFile.dateAdded if history didn't work
if not episode_data['dateAdded'] and episode.get('hasFile'):
@@ -468,11 +458,6 @@ class TVProcessor:
episode_map[(season, episode_num)] = episode_data
- # Also yield control every 20 episodes processed to prevent blocking
- if episodes_processed % 20 == 0:
- import time
- time.sleep(0.01) # 10ms yield for large episode lists
- _log("DEBUG", f"Processed {episodes_processed} episodes, yielding to allow other requests...")
if filter_set:
_log("DEBUG", f"Made {api_calls_made} Sonarr history API calls for filtered episodes (instead of all episodes)")
diff --git a/start_web.py b/start_web.py
new file mode 100644
index 0000000..7d0cfdd
--- /dev/null
+++ b/start_web.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""
+NFOGuard Web Interface Starter
+Simple script to start web interface using existing config system
+"""
+import os
+import sys
+import uvicorn
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+
+# Import existing configuration
+from config.settings import config
+
+# Import existing database and components
+from core.database import NFOGuardDatabase
+
+# Import web routes from existing system
+from api.web_routes import register_web_routes
+
+# Import authentication system
+from api.auth import SimpleAuthMiddleware, AuthSession
+
+
+def create_web_app() -> FastAPI:
+ """Create FastAPI web application"""
+ app = FastAPI(
+ title="NFOGuard Web Interface",
+ description="Web interface for NFOGuard media database management",
+ version="2.7.0-web",
+ docs_url=None, # Disable docs in production
+ redoc_url=None
+ )
+
+ return app
+
+
+def setup_static_files(app: FastAPI) -> None:
+ """Mount static file directories"""
+ static_path = os.path.join(os.path.dirname(__file__), "nfoguard-web", "static")
+ logo_path = os.path.join(os.path.dirname(__file__), "logo")
+
+ print(f"๐ Checking static path: {static_path} (exists: {os.path.exists(static_path)})")
+ print(f"๐ Checking logo path: {logo_path} (exists: {os.path.exists(logo_path)})")
+
+ if os.path.exists(static_path):
+ app.mount("/static", StaticFiles(directory=static_path), name="static")
+ print(f"โ
Mounted static files from: {static_path}")
+ else:
+ print(f"โ Static path not found: {static_path}")
+
+ if os.path.exists(logo_path):
+ app.mount("/logo", StaticFiles(directory=logo_path), name="logo")
+ print(f"โ
Mounted logo files from: {logo_path}")
+ else:
+ print(f"โ Logo path not found: {logo_path}")
+
+ # Serve index.html at root
+ @app.get("/")
+ async def serve_index():
+ index_file = os.path.join(static_path, "index.html")
+ if os.path.exists(index_file):
+ return FileResponse(index_file)
+ else:
+ return {"message": "NFOGuard Web Interface", "status": "running"}
+
+ # Serve favicon
+ @app.get("/favicon.ico")
+ async def serve_favicon():
+ # Try to serve favicon from logo directory or static files
+ favicon_paths = [
+ os.path.join(logo_path, "favicon.ico"),
+ os.path.join(static_path, "favicon.ico"),
+ os.path.join(logo_path, "NFOGuardLogo.png") # Fallback to new logo
+ ]
+
+ for favicon_path in favicon_paths:
+ if os.path.exists(favicon_path):
+ return FileResponse(favicon_path)
+
+ # Return 204 No Content if no favicon found
+ from fastapi import Response
+ return Response(status_code=204)
+
+
+def main():
+ """Main entry point for NFOGuard Web Interface"""
+ print("๐ Starting NFOGuard Web Interface...")
+
+ # Use existing config system
+ web_host = os.environ.get("WEB_HOST", "0.0.0.0")
+ web_port = int(os.environ.get("WEB_PORT", "8081"))
+
+ print(f"๐ Configuration: Port {web_port}")
+
+ # Create FastAPI app
+ app = create_web_app()
+
+ # Initialize database using existing system
+ try:
+ db = NFOGuardDatabase(config)
+ print(f"โ
Connected to database: {config.db_host}:{config.db_port}/{config.db_name}")
+ except Exception as e:
+ print(f"โ Failed to connect to database: {e}")
+ sys.exit(1)
+
+ # Setup authentication if enabled
+ auth_enabled = getattr(config, 'web_auth_enabled', False)
+ session_manager = None
+
+ if auth_enabled:
+ session_timeout = getattr(config, 'web_auth_session_timeout', 3600)
+ session_manager = AuthSession(timeout_seconds=session_timeout)
+ print(f"๐ Web authentication enabled (session timeout: {session_timeout}s)")
+ else:
+ print("๐ Web authentication disabled")
+
+ # Create dependencies for dependency injection (simplified for web-only)
+ dependencies = {
+ "db": db,
+ "config": config,
+ "nfo_manager": None, # Not needed for read-only web interface
+ "movie_processor": None, # Not needed for read-only web interface
+ "tv_processor": None, # Not needed for read-only web interface
+ "auth_enabled": auth_enabled,
+ "session_manager": session_manager
+ }
+
+ # Add authentication middleware if enabled (BEFORE routes)
+ if auth_enabled:
+ # Pass the session manager to middleware so it uses the same instance
+ app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager)
+ print("๐ Authentication middleware added to web interface")
+
+ # Setup static files and routes
+ setup_static_files(app)
+
+ # Register web routes
+ register_web_routes(app, dependencies)
+
+ print(f"๐ Starting web server on {web_host}:{web_port}")
+
+ try:
+ uvicorn.run(
+ app,
+ host=web_host,
+ port=web_port,
+ workers=1,
+ log_level="info",
+ access_log=False
+ )
+ except KeyboardInterrupt:
+ print("\n๐ Web interface shutdown by user")
+ except Exception as e:
+ print(f"โ Web interface failed: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/webhooks/webhook_batcher.py b/webhooks/webhook_batcher.py
index 6d9ea1f..ec0c971 100644
--- a/webhooks/webhook_batcher.py
+++ b/webhooks/webhook_batcher.py
@@ -196,7 +196,12 @@ class WebhookBatcher:
# Shutdown the thread pool executor
try:
- self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
+ # Use timeout parameter only if supported (Python 3.9+)
+ import sys
+ if sys.version_info >= (3, 9):
+ self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
+ else:
+ self.executor.shutdown(wait=True) # No timeout for older Python versions
_log("INFO", "Thread pool executor shut down successfully")
except Exception as e:
_log("WARNING", f"Error shutting down thread pool: {e}")