This commit is contained in:
+12
-7
@@ -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 detection paths (for identifying downloads vs existing files)
|
||||||
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
|
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
|
# DATABASE CONFIGURATION
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -142,10 +153,4 @@ SUPPRESS_TVDB_WARNINGS=true
|
|||||||
WEB_AUTH_ENABLED=false
|
WEB_AUTH_ENABLED=false
|
||||||
|
|
||||||
# Session timeout in seconds (default: 3600 = 1 hour)
|
# Session timeout in seconds (default: 3600 = 1 hour)
|
||||||
WEB_AUTH_SESSION_TIMEOUT=3600
|
WEB_AUTH_SESSION_TIMEOUT=3600
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# SERVER CONFIGURATION
|
|
||||||
# ===========================================
|
|
||||||
# Port for webhook server
|
|
||||||
PORT=8080
|
|
||||||
@@ -132,7 +132,7 @@ services:
|
|||||||
- ./.env:/app/.env:ro # Main configuration
|
- ./.env:/app/.env:ro # Main configuration
|
||||||
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||||
environment:
|
environment:
|
||||||
- PORT=8080
|
- CORE_API_PORT=8080
|
||||||
depends_on:
|
depends_on:
|
||||||
- radarr-postgres
|
- radarr-postgres
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class NFOGuardConfig:
|
|||||||
|
|
||||||
def _load_configuration(self) -> None:
|
def _load_configuration(self) -> None:
|
||||||
"""Load all configuration from environment variables"""
|
"""Load all configuration from environment variables"""
|
||||||
|
# Server configuration
|
||||||
|
self._load_server_settings()
|
||||||
|
|
||||||
# Core paths - Required
|
# Core paths - Required
|
||||||
self._load_paths()
|
self._load_paths()
|
||||||
|
|
||||||
@@ -118,6 +121,16 @@ class NFOGuardConfig:
|
|||||||
current_value=movie_paths_env
|
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:
|
def _load_external_connections(self) -> None:
|
||||||
"""Load external API and database connection settings"""
|
"""Load external API and database connection settings"""
|
||||||
# API URLs
|
# API URLs
|
||||||
|
|||||||
@@ -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'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
NFOGuard - Automated NFO file management for Radarr and Sonarr
|
NFOGuard Core - Automated NFO file management and processing engine
|
||||||
Modular architecture with webhook processing and intelligent date handling
|
Core processing container with webhooks, scanning, and database management
|
||||||
|
Web interface separated to nfoguard-web container
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -16,8 +17,7 @@ from fastapi import FastAPI
|
|||||||
# Import configuration first
|
# Import configuration first
|
||||||
from config.settings import config
|
from config.settings import config
|
||||||
|
|
||||||
# Import authentication
|
# Authentication removed - handled by separate web container
|
||||||
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
|
|
||||||
from utils.logging import _log
|
from utils.logging import _log
|
||||||
|
|
||||||
# Import core components
|
# Import core components
|
||||||
@@ -175,16 +175,8 @@ def main():
|
|||||||
# Initialize components
|
# Initialize components
|
||||||
dependencies = initialize_components()
|
dependencies = initialize_components()
|
||||||
|
|
||||||
# Add authentication dependencies
|
# Note: Authentication and web interface handled by separate nfoguard-web container
|
||||||
auth_deps = create_auth_dependencies(config)
|
_log("INFO", "Core API: Authentication handled by separate web container")
|
||||||
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")
|
|
||||||
|
|
||||||
# Store dependencies globally for signal handler access
|
# Store dependencies globally for signal handler access
|
||||||
signal_handler.dependencies = dependencies
|
signal_handler.dependencies = dependencies
|
||||||
@@ -193,10 +185,16 @@ def main():
|
|||||||
register_routes(app, dependencies)
|
register_routes(app, dependencies)
|
||||||
|
|
||||||
try:
|
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(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host="0.0.0.0",
|
host=core_host,
|
||||||
port=int(os.environ.get("PORT", "8080")),
|
port=core_port,
|
||||||
reload=False,
|
reload=False,
|
||||||
access_log=False, # Reduce logging overhead
|
access_log=False, # Reduce logging overhead
|
||||||
server_header=False, # Reduce response overhead
|
server_header=False, # Reduce response overhead
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# NFOGuard Web Core Components
|
||||||
@@ -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 |
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
@@ -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()">×</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()">×</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
Reference in New Issue
Block a user