feat: seperate webintreface
Local Docker Build (Dev) / build-dev (push) Successful in 4s

This commit is contained in:
2025-10-22 10:35:23 -04:00
parent d155b31672
commit 6cc13ce156
21 changed files with 4900 additions and 25 deletions
+11 -6
View File
@@ -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
# ===========================================
@@ -143,9 +154,3 @@ 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
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -1 +1 @@
2.6.12
2.7.0
+13
View File
@@ -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
+132
View File
@@ -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
@@ -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:
+14 -16
View File
@@ -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
+17
View File
@@ -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
+57
View File
@@ -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
+40
View File
@@ -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"]
+182
View File
@@ -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
}
}
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# NFOGuard Web Core Components
+238
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

+141
View File
@@ -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()
+15
View File
@@ -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
+888
View File
@@ -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; }
+414
View File
@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title>
<link rel="stylesheet" href="/static/css/styles.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<div class="header-logo">
<img src="/logo/NFOguardLogoPlain.png" alt="NFOGuard Logo" class="logo">
<div class="header-text">
<h1>NFOGuard</h1>
<p>Database Management & Reporting</p>
</div>
</div>
</div>
<div class="auth-status" id="auth-status" style="display: none;">
<span class="auth-user">
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
</span>
<button class="auth-logout" id="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</div>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard
</button>
<button class="nav-tab" data-tab="movies">
<i class="fas fa-film"></i> Movies
</button>
<button class="nav-tab" data-tab="tv">
<i class="fas fa-tv"></i> TV Series
</button>
<button class="nav-tab" data-tab="reports">
<i class="fas fa-chart-bar"></i> Reports
</button>
<button class="nav-tab" data-tab="tools">
<i class="fas fa-tools"></i> Tools
</button>
</nav>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Dashboard Tab -->
<div class="tab-content active" id="dashboard">
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-icon movies">
<i class="fas fa-film"></i>
</div>
<div class="stat-info">
<h3 id="movies-total">-</h3>
<p>Total Movies</p>
<small id="movies-with-dates">- with dates</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tv">
<i class="fas fa-tv"></i>
</div>
<div class="stat-info">
<h3 id="series-total">-</h3>
<p>TV Series</p>
<small id="episodes-total">- episodes</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon missing">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="stat-info">
<h3 id="missing-dates-total">-</h3>
<p>Missing Dates</p>
<small id="no-valid-source-total">- no valid source</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon activity">
<i class="fas fa-history"></i>
</div>
<div class="stat-info">
<h3 id="recent-activity">-</h3>
<p>Recent Activity</p>
<small>Last 7 days</small>
</div>
</div>
</div>
<div class="dashboard-charts">
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Movie Sources</h3>
<div id="movie-sources-chart" class="chart-container"></div>
</div>
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Episode Sources</h3>
<div id="episode-sources-chart" class="chart-container"></div>
</div>
</div>
</div>
<!-- Movies Tab -->
<div class="tab-content" id="movies">
<div class="content-header">
<h2><i class="fas fa-film"></i> Movies Database</h2>
<div class="content-controls">
<div class="search-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="movies-search" placeholder="Search title/path...">
</div>
<div class="search-box">
<i class="fas fa-hashtag"></i>
<input type="text" id="movies-imdb-search" placeholder="Search IMDb ID...">
</div>
</div>
<div class="filter-controls">
<select id="movies-filter-date">
<option value="">All Movies</option>
<option value="true">With Dates</option>
<option value="false">Missing Dates</option>
</select>
<select id="movies-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshMovies()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table" id="movies-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Movie Released</th>
<th>Date Added to Library</th>
<th>Source</th>
<th>Date Type</th>
<th>Video File</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="movies-tbody">
<tr>
<td colspan="8" class="loading">Loading movies...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="movies-pagination"></div>
</div>
<!-- TV Series Tab -->
<div class="tab-content" id="tv">
<div class="content-header">
<h2><i class="fas fa-tv"></i> TV Series Database</h2>
<div class="content-controls">
<div class="search-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="series-search" placeholder="Search title/path...">
</div>
<div class="search-box">
<i class="fas fa-hashtag"></i>
<input type="text" id="series-imdb-search" placeholder="Search IMDb ID...">
</div>
</div>
<div class="filter-controls">
<select id="series-filter-date">
<option value="">All Series</option>
<option value="complete">Fully Dated</option>
<option value="incomplete">Missing Dates</option>
<option value="none">No Dates</option>
</select>
<select id="series-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshSeries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table" id="series-table">
<thead>
<tr>
<th>Series Title</th>
<th>IMDb ID</th>
<th>Episodes</th>
<th>With Dates</th>
<th>With Video</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="series-tbody">
<tr>
<td colspan="6" class="loading">Loading series...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="series-pagination"></div>
</div>
<!-- Reports Tab -->
<div class="tab-content" id="reports">
<div class="content-header">
<h2><i class="fas fa-chart-bar"></i> Missing Dates Report</h2>
<div class="content-controls">
<button class="btn btn-primary" onclick="refreshReport()">
<i class="fas fa-sync"></i> Refresh Report
</button>
</div>
</div>
<div class="report-summary" id="report-summary">
<div class="summary-card">
<h3>Movies</h3>
<p><span id="report-movies-with">-</span> with dates</p>
<p><span id="report-movies-missing">-</span> missing dates</p>
</div>
<div class="summary-card">
<h3>Episodes</h3>
<p><span id="report-episodes-with">-</span> with dates</p>
<p><span id="report-episodes-missing">-</span> missing dates</p>
</div>
</div>
<div class="report-content">
<div class="report-section">
<h3><i class="fas fa-film"></i> Movies Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Released</th>
<th>Source</th>
<th>Smart Fix</th>
</tr>
</thead>
<tbody id="report-movies-tbody">
<tr>
<td colspan="5" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="report-section">
<h3><i class="fas fa-tv"></i> Episodes Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Series</th>
<th>Episode</th>
<th>IMDb ID</th>
<th>Aired</th>
<th>Source</th>
<th>Smart Fix</th>
</tr>
</thead>
<tbody id="report-episodes-tbody">
<tr>
<td colspan="6" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Tools Tab -->
<div class="tab-content" id="tools">
<div class="content-header">
<h2><i class="fas fa-tools"></i> Database Tools</h2>
</div>
<div class="tools-grid">
<div class="tool-card">
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
<p>Change source for multiple items at once</p>
<form id="bulk-update-form">
<div class="form-group">
<label>Media Type:</label>
<select id="bulk-media-type" required>
<option value="">Select type...</option>
<option value="movies">Movies</option>
<option value="episodes">Episodes</option>
</select>
</div>
<div class="form-group">
<label>From Source:</label>
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
</div>
<div class="form-group">
<label>To Source:</label>
<select id="bulk-new-source" required>
<option value="">Select new source...</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="manual">Manual</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
</select>
</div>
<button type="submit" class="btn btn-warning">
<i class="fas fa-exchange-alt"></i> Update Sources
</button>
</form>
</div>
<div class="tool-card">
<h3><i class="fas fa-database"></i> Database Statistics</h3>
<p>View detailed database information</p>
<div class="stats-display" id="detailed-stats">
<p>Click refresh to load detailed statistics</p>
</div>
<button class="btn btn-secondary" onclick="loadDetailedStats()">
<i class="fas fa-sync"></i> Refresh Stats
</button>
</div>
</div>
</div>
</main>
</div>
<!-- Smart Fix Modal -->
<div class="modal" id="smart-fix-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="smart-fix-title">Choose Date Source</h3>
<button class="modal-close" onclick="closeSmartFixModal()">&times;</button>
</div>
<div class="modal-body">
<div id="smart-fix-content">
<p>Loading available options...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeSmartFixModal()">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div class="modal" id="edit-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Edit Entry</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="edit-form">
<input type="hidden" id="edit-imdb-id">
<input type="hidden" id="edit-season">
<input type="hidden" id="edit-episode">
<input type="hidden" id="edit-media-type">
<div class="form-group">
<label for="edit-dateadded">Date Added:</label>
<input type="datetime-local" id="edit-dateadded">
<small>Leave empty to clear date</small>
</div>
<div class="form-group">
<label for="edit-source">Source:</label>
<select id="edit-source" required>
<option value="manual">Manual</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
<option value="no_valid_date_source">No Valid Source</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff