diff --git a/.env.example b/.env.example
index 49a2925..5b6750d 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,17 @@ 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
+# ===========================================
+# 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
+
# ===========================================
# DATABASE CONFIGURATION
# ===========================================
@@ -142,10 +153,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/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..24ba9a3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.6.12
+2.7.0
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..85cf759
--- /dev/null
+++ b/docker-compose.example.yml
@@ -0,0 +1,132 @@
+# 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
+ environment:
+ - POSTGRES_DB=${DB_NAME:-nfoguard}
+ - POSTGRES_USER=${DB_USER:-nfoguard}
+ - POSTGRES_PASSWORD=${DB_PASSWORD}
+ - TZ=${TZ:-America/New_York}
+ 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: gitea.skalas.org/sbcrumb/nfoguard:latest
+ container_name: nfoguard-core
+ restart: unless-stopped
+ env_file:
+ - .env
+ - .env.secrets
+ environment:
+ - TZ=${TZ:-America/New_York}
+ - CORE_API_HOST=0.0.0.0
+ - CORE_API_PORT=8080
+ 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:
+ build:
+ context: ./nfoguard-web
+ dockerfile: Dockerfile.web
+ container_name: nfoguard-web
+ restart: unless-stopped
+ env_file:
+ - nfoguard-web/.env.web.example
+ - nfoguard-web/.env.secrets.web.example # Create this file
+ environment:
+ - TZ=${TZ:-America/New_York}
+ - WEB_HOST=0.0.0.0
+ - WEB_PORT=8081
+ - DB_HOST=nfoguard-db
+ - CORE_API_HOST=nfoguard
+ - CORE_API_PORT=8080
+ - DB_NAME=${DB_NAME:-nfoguard}
+ - DB_USER=${DB_USER:-nfoguard}
+ - DB_PASSWORD=${DB_PASSWORD}
+ 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/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/.env.secrets.web.example b/nfoguard-web/.env.secrets.web.example
new file mode 100644
index 0000000..d33e32e
--- /dev/null
+++ b/nfoguard-web/.env.secrets.web.example
@@ -0,0 +1,17 @@
+# ===========================================
+# NFOGuard Web Interface Secrets
+# ===========================================
+# SENSITIVE CONFIGURATION - DO NOT COMMIT TO VERSION CONTROL
+# Copy this file to .env.secrets and update with your actual values
+
+# ===========================================
+# DATABASE CREDENTIALS
+# ===========================================
+# Database password for web interface access
+DB_PASSWORD=your_secure_database_password
+
+# ===========================================
+# WEB AUTHENTICATION CREDENTIALS
+# ===========================================
+# Web interface authentication (if enabled)
+WEB_AUTH_PASSWORD=your_secure_web_password
\ No newline at end of file
diff --git a/nfoguard-web/.env.web.example b/nfoguard-web/.env.web.example
new file mode 100644
index 0000000..65d248c
--- /dev/null
+++ b/nfoguard-web/.env.web.example
@@ -0,0 +1,57 @@
+# ===========================================
+# NFOGuard Web Interface Configuration
+# ===========================================
+# Configuration for the separated web interface container
+
+# ===========================================
+# WEB SERVER SETTINGS
+# ===========================================
+# Web interface server configuration
+WEB_HOST=0.0.0.0
+WEB_PORT=8081
+WEB_WORKERS=1
+WEB_DEBUG=false
+
+# Core NFOGuard API connection
+CORE_API_HOST=nfoguard
+CORE_API_PORT=8080
+
+# ===========================================
+# DATABASE CONFIGURATION (READ ACCESS)
+# ===========================================
+# Same database as core NFOGuard but optimized for web queries
+DB_TYPE=postgresql
+DB_HOST=nfoguard-db
+DB_PORT=5432
+DB_NAME=nfoguard
+DB_USER=nfoguard
+# Set DB_PASSWORD in .env.secrets file
+
+# ===========================================
+# WEB INTERFACE AUTHENTICATION
+# ===========================================
+# Optional authentication for web interface
+WEB_AUTH_ENABLED=false
+WEB_AUTH_USERNAME=admin
+# Set WEB_AUTH_PASSWORD in .env.secrets file
+WEB_AUTH_SESSION_TIMEOUT=3600
+
+# ===========================================
+# UI CUSTOMIZATION
+# ===========================================
+# Application branding
+APP_TITLE=NFOGuard
+APP_SUBTITLE=Database Management & Reporting
+
+# Interface settings
+PAGINATION_LIMIT=50
+REFRESH_INTERVAL=30
+
+# Logo configuration
+LOGO_ENABLED=true
+
+# ===========================================
+# TIMEZONE CONFIGURATION
+# ===========================================
+# Must match core NFOGuard timezone
+TZ=America/New_York
\ No newline at end of file
diff --git a/nfoguard-web/Dockerfile.web b/nfoguard-web/Dockerfile.web
new file mode 100644
index 0000000..e967644
--- /dev/null
+++ b/nfoguard-web/Dockerfile.web
@@ -0,0 +1,40 @@
+# NFOGuard Web Interface Container
+# Lightweight container for web interface only
+
+FROM python:3.11-slim
+
+# Set working directory
+WORKDIR /app
+
+# Install system dependencies (minimal)
+RUN apt-get update && apt-get install -y \
+ libpq-dev \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements and install Python dependencies
+COPY requirements_web.txt .
+RUN pip install --no-cache-dir -r requirements_web.txt
+
+# Copy web application files
+COPY config/ ./config/
+COPY core/ ./core/
+COPY api/ ./api/
+COPY static/ ./static/
+COPY logo/ ./logo/
+COPY main_web.py .
+
+# Create non-root user for security
+RUN adduser --disabled-password --gecos '' webuser && \
+ chown -R webuser:webuser /app
+USER webuser
+
+# Expose web interface port (configurable via WEB_PORT env var)
+EXPOSE 8081
+
+# Health check for web interface
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:${WEB_PORT:-8081}/ || exit 1
+
+# Run web interface
+CMD ["python", "main_web.py"]
\ 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..302352f
--- /dev/null
+++ b/nfoguard-web/api/web_routes.py
@@ -0,0 +1,1216 @@
+"""
+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
+
+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..788e61f
--- /dev/null
+++ b/nfoguard-web/main_web.py
@@ -0,0 +1,141 @@
+"""
+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 to path for imports
+sys.path.append(str(Path(__file__).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/requirements_web.txt b/nfoguard-web/requirements_web.txt
new file mode 100644
index 0000000..6dd2a1b
--- /dev/null
+++ b/nfoguard-web/requirements_web.txt
@@ -0,0 +1,15 @@
+# NFOGuard Web Interface Dependencies
+# Minimal dependencies for web-only container
+
+# Core web framework
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+
+# Database connectivity
+psycopg2-binary==2.9.7
+
+# Authentication and sessions
+python-multipart==0.0.6
+
+# Utilities
+python-dotenv==1.0.0
\ 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..c29d650
--- /dev/null
+++ b/nfoguard-web/static/css/styles.css
@@ -0,0 +1,888 @@
+/* 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;
+ filter: brightness(0) invert(1); /* Make logo white */
+}
+
+.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..955a1e5
--- /dev/null
+++ b/nfoguard-web/static/index.html
@@ -0,0 +1,414 @@
+
+
+
+
+
+ NFOGuard - Database Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
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..aa1b7e2
--- /dev/null
+++ b/nfoguard-web/static/js/app.js
@@ -0,0 +1,1437 @@
+// NFOGuard Web Interface JavaScript
+
+// Global state
+let currentTab = 'dashboard';
+let currentMoviesPage = 1;
+let currentSeriesPage = 1;
+let dashboardData = null;
+
+// Initialize app
+document.addEventListener('DOMContentLoaded', function() {
+ initializeTabs();
+ initializeEventListeners();
+ checkAuthStatus(); // Check authentication status on page load
+ loadDashboard();
+ loadSeriesSources();
+});
+
+// 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();
+ 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);
+ document.getElementById('bulk-update-form').addEventListener('submit', handleBulkUpdate);
+}
+
+// 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();
+ } catch (error) {
+ console.error('Failed to load dashboard:', error);
+ }
+}
+
+function updateDashboardStats() {
+ if (!dashboardData) return;
+
+ // Debug: Log dashboard data to see what fields are available
+ console.log('Dashboard data received:', dashboardData);
+
+ 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;
+ console.log(`DEBUG: loadMovies called with page=${page}, calculated skip=${skip}`);
+
+ 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;
+ } 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;
+ console.log(`DEBUG: loadSeries called with page=${page}, calculated skip=${skip}`);
+
+ 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);
+ }
+}
+
+async function handleBulkUpdate(event) {
+ event.preventDefault();
+
+ const mediaType = document.getElementById('bulk-media-type').value;
+ const oldSource = document.getElementById('bulk-old-source').value;
+ const newSource = document.getElementById('bulk-new-source').value;
+
+ if (!mediaType || !oldSource || !newSource) {
+ showToast('Please fill in all fields', 'warning');
+ return;
+ }
+
+ if (!confirm(`This will update all ${mediaType} with source "${oldSource}" to "${newSource}". Continue?`)) {
+ return;
+ }
+
+ try {
+ const result = await apiCall('/api/bulk/update-source', {
+ method: 'POST',
+ body: JSON.stringify({
+ media_type: mediaType,
+ old_source: oldSource,
+ new_source: newSource
+ })
+ });
+
+ showToast(result.message, 'success');
+
+ // Reset form
+ document.getElementById('bulk-update-form').reset();
+
+ // Refresh current tab
+ if (currentTab === 'movies') loadMovies(currentMoviesPage);
+ if (currentTab === 'tv') loadSeries(currentSeriesPage);
+ if (currentTab === 'reports') loadReport();
+ if (currentTab === 'dashboard') loadDashboard();
+
+ } catch (error) {
+ console.error('Bulk update failed:', 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 shouldn't happen if middleware is working, but handle it
+ console.warn('Auth enabled but not authenticated - 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');
+ }
+}
\ No newline at end of file