Compare commits
42 Commits
main
...
3e07b4a31a
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e07b4a31a | |||
| 1f4f433e50 | |||
| 28671405b6 | |||
| e920487543 | |||
| a8176d2f05 | |||
| 4095ca18c8 | |||
| 0649895cb5 | |||
| 9489b839f5 | |||
| d0dc141afb | |||
| 3baf17db8a | |||
| 7261ba8884 | |||
| 93fd3789e2 | |||
| 296e668db6 | |||
| 9acb592d3f | |||
| f18df470f8 | |||
| 2206ebafc1 | |||
| 864934d66e | |||
| ce17ae9ece | |||
| 696ac7455d | |||
| ea4611d8a2 | |||
| b6343874f7 | |||
| d137f838e0 | |||
| 77ff193762 | |||
| 1a60d8fd41 | |||
| cbe2fadab6 | |||
| 9397f115e3 | |||
| c1829b2aef | |||
| ddd26d311b | |||
| c2d796dbd9 | |||
| 0580eb68f9 | |||
| 9b03fb50ea | |||
| 6f5341cbc6 | |||
| bc2f367951 | |||
| ff2c2cdc04 | |||
| 60c7e3efa2 | |||
| ccd20dcb44 | |||
| 5bdfdb52f8 | |||
| cc0daf0e12 | |||
| aa5a07a50b | |||
| ef2103f3d5 | |||
| 6cc13ce156 | |||
| d155b31672 |
+18
-6
@@ -20,6 +20,23 @@ SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
|
||||
# Download detection paths (for identifying downloads vs existing files)
|
||||
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
|
||||
|
||||
# ===========================================
|
||||
# SERVER CONFIGURATION
|
||||
# ===========================================
|
||||
# Timezone for container logging and timestamps
|
||||
TZ=America/New_York
|
||||
|
||||
# NFOGuard Core API (webhooks, processing, database management)
|
||||
CORE_API_HOST=0.0.0.0
|
||||
CORE_API_PORT=8080
|
||||
|
||||
# NFOGuard Web Interface (dashboard, series/movie management)
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8081
|
||||
|
||||
# External port where web interface is accessible (for dynamic port reference)
|
||||
WEB_EXTERNAL_PORT=8081
|
||||
|
||||
# ===========================================
|
||||
# DATABASE CONFIGURATION
|
||||
# ===========================================
|
||||
@@ -34,6 +51,7 @@ DB_TYPE=postgresql
|
||||
DB_HOST=nfoguard-db # Container name from docker-compose
|
||||
DB_PORT=5432
|
||||
DB_NAME=nfoguard
|
||||
DB_USER=nfoguard
|
||||
|
||||
# Legacy SQLite Configuration (Deprecated in v2.6+)
|
||||
# Only use for existing SQLite installations
|
||||
@@ -143,9 +161,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
|
||||
@@ -11,6 +11,10 @@ jobs:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Pre-cleanup
|
||||
run: |
|
||||
docker system prune -f
|
||||
docker builder prune -f --filter until=24h
|
||||
- name: Checkout code (DEV)
|
||||
run: |
|
||||
echo "Current workspace: $(pwd)"
|
||||
|
||||
@@ -11,6 +11,15 @@ jobs:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Pre-cleanup Docker space
|
||||
run: |
|
||||
echo "🧹 Cleaning up Docker space before build..."
|
||||
docker system prune -f
|
||||
docker builder prune -f --filter until=24h
|
||||
docker image prune -af --filter until=48h
|
||||
echo "📊 Docker space after cleanup:"
|
||||
docker system df
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "Current workspace: $(pwd)"
|
||||
@@ -203,6 +212,15 @@ jobs:
|
||||
echo ""
|
||||
echo "🐳 Pull with: docker pull 192.168.253.221:3000/sbcrumb/nfoguard:${VERSION}-gitea"
|
||||
|
||||
- name: Post-cleanup Docker space
|
||||
if: always() # Run even if build fails
|
||||
run: |
|
||||
echo "🧹 Final cleanup to prevent disk space issues..."
|
||||
docker system prune -f
|
||||
docker builder prune -af --filter until=24h
|
||||
echo "📊 Final Docker space usage:"
|
||||
docker system df
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: host
|
||||
|
||||
@@ -32,6 +32,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Copy web starter file to root directory
|
||||
COPY start_web.py .
|
||||
|
||||
# Create git metadata for version detection based on build arg
|
||||
RUN mkdir -p .git && \
|
||||
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+4
-2
@@ -72,10 +72,10 @@ class AuthSession:
|
||||
class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple authentication middleware for web interface routes"""
|
||||
|
||||
def __init__(self, app, config):
|
||||
def __init__(self, app, config, session_manager=None):
|
||||
super().__init__(app)
|
||||
self.config = config
|
||||
self.session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
self.session_manager = session_manager or AuthSession(config.web_auth_session_timeout)
|
||||
self.security = HTTPBasic()
|
||||
|
||||
# Routes that require authentication (web interface)
|
||||
@@ -92,6 +92,8 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
self.public_routes = [
|
||||
"/webhook/",
|
||||
"/health",
|
||||
"/logo/", # Logo files should always be accessible
|
||||
"/favicon.ico", # Favicon should always be accessible
|
||||
"/ping",
|
||||
"/api/v1/health",
|
||||
"/api/v1/metrics",
|
||||
|
||||
+247
-187
@@ -15,11 +15,24 @@ from api.models import (
|
||||
SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
|
||||
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
|
||||
)
|
||||
from api.web_routes import (
|
||||
get_movies_list, get_tv_series_list, get_series_episodes, get_series_sources, get_missing_dates_report,
|
||||
get_dashboard_stats, update_movie_date, update_episode_date, bulk_update_source,
|
||||
get_movie_date_options, get_episode_date_options, debug_series_date_distribution
|
||||
)
|
||||
# Web routes removed - handled by separate web container
|
||||
|
||||
# Global scan status tracking for detailed progress
|
||||
scan_status = {
|
||||
"scanning": False,
|
||||
"scan_type": None,
|
||||
"scan_mode": None,
|
||||
"start_time": None,
|
||||
"current_operation": None,
|
||||
"tv_series_processed": 0,
|
||||
"tv_series_total": 0,
|
||||
"tv_series_skipped": 0,
|
||||
"movies_processed": 0,
|
||||
"movies_total": 0,
|
||||
"movies_skipped": 0,
|
||||
"current_item": None,
|
||||
"last_update": None
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------
|
||||
@@ -569,6 +582,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
|
||||
print(f"🚀 MANUAL SCAN STARTED: {scan_type} scan (mode: {scan_mode}) initiated at {local_start.strftime('%Y-%m-%d %H:%M:%S')}{tz_display}")
|
||||
|
||||
# Initialize scan tracking
|
||||
start_scan_tracking(scan_type, scan_mode)
|
||||
|
||||
# Initialize counters for scan statistics
|
||||
tv_series_total = 0
|
||||
tv_series_skipped = 0
|
||||
@@ -629,86 +645,110 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
else:
|
||||
# Full series processing - scan subdirectories
|
||||
import re
|
||||
tv_count = 0
|
||||
|
||||
# Count total series first for progress tracking
|
||||
tv_series_list = []
|
||||
for item in scan_path.iterdir():
|
||||
if (item.is_dir() and
|
||||
not item.name.lower().startswith('season') and
|
||||
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
|
||||
nfo_manager.parse_imdb_from_path(item)):
|
||||
tv_series_list.append(item)
|
||||
|
||||
tv_series_count = len(tv_series_list)
|
||||
update_scan_status("tv", tv_series_total=tv_series_count)
|
||||
print(f"INFO: Found {tv_series_count} TV series to process")
|
||||
|
||||
tv_count = 0
|
||||
for item in tv_series_list:
|
||||
# Check for shutdown signal at start of each item
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
||||
return
|
||||
|
||||
if (item.is_dir() and
|
||||
not item.name.lower().startswith('season') and
|
||||
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
|
||||
nfo_manager.parse_imdb_from_path(item)):
|
||||
tv_count += 1
|
||||
try:
|
||||
# Determine force_scan based on scan mode
|
||||
force_scan = (scan_mode == "full")
|
||||
result = tv_processor.process_series(item, force_scan=force_scan)
|
||||
tv_series_total += 1
|
||||
if result == "skipped":
|
||||
tv_series_skipped += 1
|
||||
elif result == "processed":
|
||||
tv_series_processed += 1
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing TV series {item}: {e}")
|
||||
tv_series_total += 1
|
||||
tv_count += 1
|
||||
update_scan_status(current_item=item.name, tv_series_processed=tv_count)
|
||||
|
||||
# Yield control every TV series to allow other requests
|
||||
if tv_count % 1 == 0:
|
||||
await asyncio.sleep(0.2) # 200ms yield to process other requests
|
||||
print(f"INFO: Processed {tv_count} TV series, yielding to other requests...")
|
||||
try:
|
||||
# Determine force_scan based on scan mode
|
||||
force_scan = (scan_mode == "full")
|
||||
result = tv_processor.process_series(item, force_scan=force_scan)
|
||||
tv_series_total += 1
|
||||
if result == "skipped":
|
||||
tv_series_skipped += 1
|
||||
elif result == "processed":
|
||||
tv_series_processed += 1
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing TV series {item}: {e}")
|
||||
tv_series_total += 1
|
||||
|
||||
# Check for shutdown signal
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
||||
return
|
||||
# Yield control every TV series to allow other requests
|
||||
if tv_count % 1 == 0:
|
||||
await asyncio.sleep(0.2) # 200ms yield to process other requests
|
||||
print(f"INFO: Processed {tv_count} TV series, yielding to other requests...")
|
||||
|
||||
# Check for shutdown signal
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
||||
return
|
||||
|
||||
if scan_type in ["both", "movies"] and scan_path in config.movie_paths:
|
||||
print(f"INFO: Scanning movies in: {scan_path}")
|
||||
movie_count = 0
|
||||
update_scan_status("movies", current_item="Counting movies...")
|
||||
|
||||
# Count total movies first for progress tracking
|
||||
movie_list = []
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
|
||||
movie_list.append(item)
|
||||
|
||||
movie_total_count = len(movie_list)
|
||||
update_scan_status(movies_total=movie_total_count)
|
||||
print(f"INFO: Found {movie_total_count} movies to process")
|
||||
|
||||
movie_count = 0
|
||||
for item in movie_list:
|
||||
# Check for shutdown signal at start of each movie
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
||||
return
|
||||
|
||||
if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
|
||||
movie_count += 1
|
||||
print(f"INFO: Processing movie: {item.name}")
|
||||
try:
|
||||
# Determine force_scan based on scan mode
|
||||
force_scan = (scan_mode == "full")
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
|
||||
movie_total += 1
|
||||
if result == "skipped":
|
||||
movie_skipped += 1
|
||||
elif result == "processed":
|
||||
movie_processed += 1
|
||||
elif result == "no_video_files":
|
||||
print(f"INFO: Skipped empty directory: {item.name}")
|
||||
movie_skipped += 1
|
||||
elif result == "shutdown":
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie scan gracefully")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing movie {item}: {e}")
|
||||
movie_total += 1
|
||||
movie_count += 1
|
||||
update_scan_status(current_item=item.name, movies_processed=movie_count)
|
||||
print(f"INFO: Processing movie: {item.name}")
|
||||
try:
|
||||
# Determine force_scan based on scan mode
|
||||
force_scan = (scan_mode == "full")
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
result = movie_processor.process_movie(item, webhook_mode=False, force_scan=force_scan, shutdown_event=shutdown_event)
|
||||
movie_total += 1
|
||||
if result == "skipped":
|
||||
movie_skipped += 1
|
||||
elif result == "processed":
|
||||
movie_processed += 1
|
||||
elif result == "no_video_files":
|
||||
print(f"INFO: Skipped empty directory: {item.name}")
|
||||
movie_skipped += 1
|
||||
elif result == "shutdown":
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie scan gracefully")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing movie {item}: {e}")
|
||||
movie_total += 1
|
||||
|
||||
# Yield control every 2 movies to allow other requests (webhooks, web interface)
|
||||
if movie_count % 2 == 0:
|
||||
await asyncio.sleep(0.2) # 200ms yield to process other requests
|
||||
print(f"INFO: Processed {movie_count} movies, yielding to other requests...")
|
||||
# Yield control every 2 movies to allow other requests (webhooks, web interface)
|
||||
if movie_count % 2 == 0:
|
||||
await asyncio.sleep(0.2) # 200ms yield to process other requests
|
||||
print(f"INFO: Processed {movie_count} movies, yielding to other requests...")
|
||||
|
||||
# Check for shutdown signal
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
||||
return
|
||||
# Check for shutdown signal
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping scan gracefully")
|
||||
return
|
||||
|
||||
print(f"INFO: Completed movie scan: {movie_count} movies processed in {scan_path}")
|
||||
|
||||
@@ -737,6 +777,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
print(f"✅ MANUAL SCAN COMPLETED: {scan_type} scan (mode: {scan_mode}) finished at {local_end.strftime('%Y-%m-%d %H:%M:%S')}{tz_display}")
|
||||
print(f"⏱️ MANUAL SCAN DURATION: {duration_str} (total time: {duration.total_seconds():.1f} seconds)")
|
||||
|
||||
# Stop scan tracking
|
||||
stop_scan_tracking()
|
||||
|
||||
# Print optimization statistics for TV scans
|
||||
if scan_type in ["both", "tv"] and tv_series_total > 0:
|
||||
print(f"📊 TV SCAN OPTIMIZATION: Total: {tv_series_total}, Processed: {tv_series_processed}, Skipped: {tv_series_skipped}")
|
||||
@@ -1736,6 +1779,113 @@ async def backfill_movie_release_dates(dependencies: dict):
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Scan Status Functions
|
||||
# ---------------------------
|
||||
|
||||
async def get_scan_status():
|
||||
"""Get detailed scan status with progress information"""
|
||||
global scan_status
|
||||
|
||||
if not scan_status["scanning"]:
|
||||
return {"scanning": False, "message": "No active scan"}
|
||||
|
||||
# Calculate elapsed time
|
||||
from datetime import datetime
|
||||
if scan_status["start_time"]:
|
||||
elapsed_seconds = int((datetime.now() - scan_status["start_time"]).total_seconds())
|
||||
if elapsed_seconds >= 60:
|
||||
minutes = elapsed_seconds // 60
|
||||
seconds = elapsed_seconds % 60
|
||||
elapsed_str = f"{minutes}m {seconds}s"
|
||||
else:
|
||||
elapsed_str = f"{elapsed_seconds}s"
|
||||
else:
|
||||
elapsed_str = "unknown"
|
||||
|
||||
# Build detailed status message
|
||||
if scan_status["current_operation"] == "tv":
|
||||
if scan_status["tv_series_total"] > 0:
|
||||
message = f"Processing TV series ({scan_status['tv_series_processed']}/{scan_status['tv_series_total']}) - {elapsed_str} elapsed"
|
||||
else:
|
||||
message = f"Processed {scan_status['tv_series_processed']} TV series - {elapsed_str} elapsed"
|
||||
elif scan_status["current_operation"] == "movies":
|
||||
if scan_status["movies_total"] > 0:
|
||||
message = f"Processing movies ({scan_status['movies_processed']}/{scan_status['movies_total']}) - {elapsed_str} elapsed"
|
||||
else:
|
||||
message = f"Processed {scan_status['movies_processed']} movies - {elapsed_str} elapsed"
|
||||
else:
|
||||
message = f"Scan in progress - {elapsed_str} elapsed"
|
||||
|
||||
# Add current item if available
|
||||
if scan_status["current_item"]:
|
||||
message += f" | Current: {scan_status['current_item']}"
|
||||
|
||||
return {
|
||||
"scanning": True,
|
||||
"message": message,
|
||||
"scan_type": scan_status["scan_type"],
|
||||
"scan_mode": scan_status["scan_mode"],
|
||||
"elapsed_seconds": elapsed_seconds if scan_status["start_time"] else 0,
|
||||
"current_operation": scan_status["current_operation"],
|
||||
"tv_series_processed": scan_status["tv_series_processed"],
|
||||
"tv_series_total": scan_status["tv_series_total"],
|
||||
"tv_series_skipped": scan_status["tv_series_skipped"],
|
||||
"movies_processed": scan_status["movies_processed"],
|
||||
"movies_total": scan_status["movies_total"],
|
||||
"movies_skipped": scan_status["movies_skipped"],
|
||||
"current_item": scan_status["current_item"]
|
||||
}
|
||||
|
||||
def update_scan_status(operation=None, current_item=None, **kwargs):
|
||||
"""Update scan status with new progress information"""
|
||||
global scan_status
|
||||
|
||||
if operation:
|
||||
scan_status["current_operation"] = operation
|
||||
if current_item:
|
||||
scan_status["current_item"] = current_item
|
||||
|
||||
# Update counters
|
||||
for key, value in kwargs.items():
|
||||
if key in scan_status:
|
||||
scan_status[key] = value
|
||||
|
||||
scan_status["last_update"] = datetime.now()
|
||||
|
||||
def start_scan_tracking(scan_type, scan_mode):
|
||||
"""Initialize scan tracking"""
|
||||
global scan_status
|
||||
|
||||
scan_status.update({
|
||||
"scanning": True,
|
||||
"scan_type": scan_type,
|
||||
"scan_mode": scan_mode,
|
||||
"start_time": datetime.now(),
|
||||
"current_operation": None,
|
||||
"tv_series_processed": 0,
|
||||
"tv_series_total": 0,
|
||||
"tv_series_skipped": 0,
|
||||
"movies_processed": 0,
|
||||
"movies_total": 0,
|
||||
"movies_skipped": 0,
|
||||
"current_item": None,
|
||||
"last_update": datetime.now()
|
||||
})
|
||||
|
||||
def stop_scan_tracking():
|
||||
"""Stop scan tracking"""
|
||||
global scan_status
|
||||
|
||||
scan_status.update({
|
||||
"scanning": False,
|
||||
"scan_type": None,
|
||||
"scan_mode": None,
|
||||
"start_time": None,
|
||||
"current_operation": None,
|
||||
"current_item": None
|
||||
})
|
||||
|
||||
# ---------------------------
|
||||
# Route Registration
|
||||
# ---------------------------
|
||||
@@ -1826,6 +1976,10 @@ def register_routes(app, dependencies: dict):
|
||||
async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", scan_mode: str = "smart"):
|
||||
return await manual_scan(background_tasks, path, scan_type, scan_mode, dependencies)
|
||||
|
||||
@app.get("/api/scan/status")
|
||||
async def _scan_status():
|
||||
return await get_scan_status()
|
||||
|
||||
@app.post("/tv/scan-season")
|
||||
async def _scan_tv_season(background_tasks: BackgroundTasks, request: TVSeasonRequest):
|
||||
return await scan_tv_season(background_tasks, request, dependencies)
|
||||
@@ -1859,131 +2013,37 @@ def register_routes(app, dependencies: dict):
|
||||
app.include_router(monitoring_router)
|
||||
|
||||
# ---------------------------
|
||||
# Web Interface API Routes
|
||||
# Web Interface Moved to Separate Container
|
||||
# ---------------------------
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
async def _dashboard_stats():
|
||||
"""Get dashboard statistics"""
|
||||
return await get_dashboard_stats(dependencies)
|
||||
|
||||
@app.get("/api/movies")
|
||||
async def _movies_list(skip: int = 0, limit: int = 100, has_date: Optional[bool] = None,
|
||||
source_filter: Optional[str] = None, search: Optional[str] = None,
|
||||
imdb_search: Optional[str] = None):
|
||||
"""Get paginated movies list with filtering"""
|
||||
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
|
||||
|
||||
@app.get("/api/series")
|
||||
async def _series_list(skip: int = 0, limit: int = 50, search: Optional[str] = None,
|
||||
imdb_search: Optional[str] = None, date_filter: Optional[str] = None,
|
||||
source_filter: Optional[str] = None):
|
||||
"""Get paginated TV series list with filtering"""
|
||||
return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter)
|
||||
|
||||
@app.get("/api/series/{imdb_id}/episodes")
|
||||
async def _series_episodes(imdb_id: str):
|
||||
"""Get episodes for a specific series"""
|
||||
return await get_series_episodes(dependencies, imdb_id)
|
||||
|
||||
@app.get("/api/series/sources")
|
||||
async def _series_sources():
|
||||
"""Get list of available episode sources for filtering"""
|
||||
return await get_series_sources(dependencies)
|
||||
|
||||
@app.get("/api/debug/series-date-distribution")
|
||||
async def _debug_series_dates():
|
||||
"""Debug endpoint showing TV series date distribution"""
|
||||
return await debug_series_date_distribution(dependencies)
|
||||
|
||||
@app.get("/api/reports/missing-dates")
|
||||
async def _missing_dates_report():
|
||||
"""Get report of content missing dateadded"""
|
||||
return await get_missing_dates_report(dependencies)
|
||||
|
||||
@app.put("/api/movies/{imdb_id}")
|
||||
async def _update_movie(imdb_id: str, request: MovieUpdateRequest):
|
||||
"""Update movie dateadded"""
|
||||
return await update_movie_date(dependencies, imdb_id, request.dateadded, request.source)
|
||||
|
||||
@app.put("/api/episodes/{imdb_id}/{season}/{episode}")
|
||||
async def _update_episode(imdb_id: str, season: int, episode: int, request: EpisodeUpdateRequest):
|
||||
"""Update episode dateadded"""
|
||||
return await update_episode_date(dependencies, imdb_id, season, episode, request.dateadded, request.source)
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def _logout(request: Request, response: Response):
|
||||
"""Logout endpoint - clears session"""
|
||||
session_manager = dependencies.get("session_manager")
|
||||
if session_manager:
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token:
|
||||
session_manager.delete_session(session_token)
|
||||
|
||||
response.delete_cookie("nfoguard_session")
|
||||
return {"status": "logged_out", "message": "Session cleared"}
|
||||
|
||||
@app.get("/api/auth/status")
|
||||
async def _auth_status(request: Request):
|
||||
"""Check authentication status"""
|
||||
auth_enabled = dependencies.get("auth_enabled", False)
|
||||
|
||||
if not auth_enabled:
|
||||
return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"}
|
||||
|
||||
session_manager = dependencies.get("session_manager")
|
||||
if not session_manager:
|
||||
return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"}
|
||||
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token:
|
||||
username = session_manager.get_session_user(session_token)
|
||||
if username:
|
||||
return {"authenticated": True, "auth_enabled": True, "username": username}
|
||||
|
||||
return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"}
|
||||
|
||||
@app.post("/api/bulk/update-source")
|
||||
async def _bulk_update_source(request: BulkUpdateRequest):
|
||||
"""Bulk update source for movies or episodes"""
|
||||
return await bulk_update_source(dependencies, request.media_type, request.old_source, request.new_source)
|
||||
|
||||
@app.get("/api/movies/{imdb_id}/date-options")
|
||||
async def _movie_date_options(imdb_id: str):
|
||||
"""Get available date options for a movie"""
|
||||
return await get_movie_date_options(dependencies, imdb_id)
|
||||
|
||||
@app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
|
||||
async def _episode_date_options(imdb_id: str, season: int, episode: int):
|
||||
"""Get available date options for an episode"""
|
||||
return await get_episode_date_options(dependencies, imdb_id, season, episode)
|
||||
|
||||
@app.get("/api/debug/movie/{imdb_id}/raw")
|
||||
async def _debug_movie_raw(imdb_id: str):
|
||||
"""Debug endpoint to see raw movie database data"""
|
||||
db = dependencies["db"]
|
||||
movie = db.get_movie_dates(imdb_id)
|
||||
if not movie:
|
||||
raise HTTPException(status_code=404, detail="Movie not found")
|
||||
return {"raw_data": dict(movie), "imdb_id": imdb_id}
|
||||
# Web interface routes have been moved to the nfoguard-web container
|
||||
# for performance isolation. The core container only handles:
|
||||
# - Webhooks (/webhook/*)
|
||||
# - Manual scans (/manual/*)
|
||||
# - Database operations (/database/*)
|
||||
# - Health checks (/health)
|
||||
#
|
||||
# Web interface available on separate container port 8081
|
||||
# ---------------------------
|
||||
# Static Web Interface
|
||||
# Core API - No Web Interface
|
||||
# ---------------------------
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
# Serve static files for web interface
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
@app.get("/")
|
||||
async def _serve_index():
|
||||
"""Serve web interface index page"""
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
else:
|
||||
return {"message": "NFOGuard Web Interface - API endpoints available at /api/", "api_docs": "/docs"}
|
||||
async def _core_info():
|
||||
"""Core container API information - Web interface on separate container"""
|
||||
import os
|
||||
# Get configured web port from environment or config
|
||||
web_port = os.environ.get("WEB_EXTERNAL_PORT",
|
||||
getattr(dependencies.get("config", None), "web_api_port", "8081"))
|
||||
|
||||
return {
|
||||
"service": "NFOGuard Core Processing Engine",
|
||||
"version": "2.7.0",
|
||||
"message": f"Web interface available on separate container (port {web_port})",
|
||||
"api_endpoints": {
|
||||
"health": "/health",
|
||||
"webhooks": "/webhook/*",
|
||||
"manual_scans": "/manual/*",
|
||||
"database": "/database/*",
|
||||
"api_docs": "/docs"
|
||||
}
|
||||
}
|
||||
@@ -1214,3 +1214,196 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int
|
||||
"current_data": episode_data,
|
||||
"options": options
|
||||
}
|
||||
|
||||
|
||||
def register_web_routes(app, dependencies):
|
||||
"""Register all web API routes with FastAPI app"""
|
||||
from fastapi import Request, Response
|
||||
|
||||
# Dashboard and stats endpoints
|
||||
@app.get("/api/dashboard")
|
||||
async def api_dashboard():
|
||||
return await get_dashboard_stats(dependencies)
|
||||
|
||||
@app.get("/api/dashboard/stats")
|
||||
async def api_dashboard_stats():
|
||||
return await get_dashboard_stats(dependencies)
|
||||
|
||||
# Movies endpoints
|
||||
@app.get("/api/movies")
|
||||
async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None,
|
||||
source_filter: str = None, search: str = None, imdb_search: str = None):
|
||||
return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search)
|
||||
|
||||
@app.post("/api/movies/{imdb_id}/update-date")
|
||||
async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
|
||||
@app.put("/api/movies/{imdb_id}")
|
||||
async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
|
||||
@app.get("/api/movies/{imdb_id}/date-options")
|
||||
async def api_movie_date_options(imdb_id: str):
|
||||
return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."}
|
||||
|
||||
# TV series endpoints
|
||||
@app.get("/api/series")
|
||||
async def api_series_list(skip: int = 0, limit: int = 50, search: str = None,
|
||||
imdb_search: str = None, date_filter: str = None, source_filter: str = None):
|
||||
return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter)
|
||||
|
||||
@app.get("/api/series/{imdb_id}/episodes")
|
||||
async def api_series_episodes(imdb_id: str):
|
||||
return await get_series_episodes(dependencies, imdb_id)
|
||||
|
||||
@app.get("/api/series/sources")
|
||||
async def api_series_sources():
|
||||
return await get_series_sources(dependencies)
|
||||
|
||||
@app.get("/api/series/debug/date-distribution")
|
||||
async def api_debug_series_date_distribution():
|
||||
return await debug_series_date_distribution(dependencies)
|
||||
|
||||
# Episode endpoints
|
||||
@app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date")
|
||||
async def api_update_episode_date(imdb_id: str, season: int, episode: int,
|
||||
dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
|
||||
@app.put("/api/episodes/{imdb_id}/{season}/{episode}")
|
||||
async def api_update_episode(imdb_id: str, season: int, episode: int,
|
||||
dateadded: str = None, source: str = "manual"):
|
||||
return {"error": "Updates not available in web container. Use core container on port 8085."}
|
||||
|
||||
@app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options")
|
||||
async def api_episode_date_options(imdb_id: str, season: int, episode: int):
|
||||
return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."}
|
||||
|
||||
# Bulk operations
|
||||
@app.post("/api/bulk/update-source")
|
||||
async def api_bulk_update_source(media_type: str, old_source: str, new_source: str):
|
||||
return {"error": "Bulk operations not available in web container. Use core container on port 8085."}
|
||||
|
||||
# Reports
|
||||
@app.get("/api/reports/missing-dates")
|
||||
async def api_missing_dates_report():
|
||||
return await get_missing_dates_report(dependencies)
|
||||
|
||||
# Authentication endpoints (for web interface compatibility)
|
||||
@app.get("/api/auth/status")
|
||||
async def api_auth_status(request: Request):
|
||||
"""Check authentication status"""
|
||||
auth_enabled = dependencies.get("auth_enabled", False)
|
||||
|
||||
if not auth_enabled:
|
||||
return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"}
|
||||
|
||||
session_manager = dependencies.get("session_manager")
|
||||
if not session_manager:
|
||||
return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"}
|
||||
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token:
|
||||
username = session_manager.get_session_user(session_token)
|
||||
if username:
|
||||
return {"authenticated": True, "auth_enabled": True, "username": username}
|
||||
|
||||
return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def api_auth_logout(request: Request, response: Response):
|
||||
"""Logout endpoint - clears session"""
|
||||
session_manager = dependencies.get("session_manager")
|
||||
if session_manager:
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token:
|
||||
session_manager.delete_session(session_token)
|
||||
|
||||
response.delete_cookie("nfoguard_session")
|
||||
return {"status": "logged_out", "message": "Session cleared"}
|
||||
|
||||
# Manual scan endpoints (proxy to core container)
|
||||
@app.post("/manual/scan")
|
||||
async def api_manual_scan(request: Request):
|
||||
"""Proxy manual scan requests to core container"""
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
|
||||
# Get core container URL
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard-core")
|
||||
core_port = os.environ.get("CORE_API_PORT", "8080")
|
||||
|
||||
# Forward query parameters from the request
|
||||
query_string = str(request.url.query) if request.url.query else ""
|
||||
core_url = f"http://{core_host}:{core_port}/manual/scan"
|
||||
if query_string:
|
||||
core_url += f"?{query_string}"
|
||||
|
||||
try:
|
||||
# Create request with timeout (no body needed for query parameters)
|
||||
req = urllib.request.Request(core_url, method='POST')
|
||||
|
||||
# Make request with timeout
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
response_data = response.read().decode('utf-8')
|
||||
return json.loads(response_data)
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
raise HTTPException(status_code=e.code, detail=f"Core container HTTP error: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Could not connect to core container: {str(e)}")
|
||||
except socket.timeout:
|
||||
raise HTTPException(status_code=504, detail="Core container request timed out")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Manual scan request failed: {str(e)}")
|
||||
|
||||
# Simple scan tracking (since we can't reliably access docker logs from container)
|
||||
scan_tracking = {"last_scan_time": None, "scanning": False}
|
||||
|
||||
@app.post("/api/scan/track")
|
||||
async def track_scan_start():
|
||||
"""Called when a scan is initiated to track timing"""
|
||||
from datetime import datetime
|
||||
scan_tracking["last_scan_time"] = datetime.now()
|
||||
scan_tracking["scanning"] = True
|
||||
return {"status": "tracked"}
|
||||
|
||||
@app.get("/api/scan/status")
|
||||
async def api_scan_status():
|
||||
"""Proxy scan status requests to core container for detailed progress"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
|
||||
# Get core container connection details
|
||||
core_host = os.environ.get("CORE_API_HOST", "nfoguard-core")
|
||||
core_port = os.environ.get("CORE_API_PORT", "8080")
|
||||
|
||||
try:
|
||||
# Call core container's detailed scan status endpoint
|
||||
status_url = f"http://{core_host}:{core_port}/api/scan/status"
|
||||
req = urllib.request.Request(status_url)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
status_data = json.loads(response.read().decode())
|
||||
return status_data
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
# Core container doesn't have the endpoint, fallback to simple tracking
|
||||
return {"scanning": False, "message": "Detailed status not available"}
|
||||
else:
|
||||
return {"scanning": False, "message": f"Core container error: {e.code}"}
|
||||
except (urllib.error.URLError, socket.timeout):
|
||||
return {"scanning": False, "message": "Core container unavailable"}
|
||||
except json.JSONDecodeError:
|
||||
return {"scanning": False, "message": "Invalid response from core container"}
|
||||
except Exception as e:
|
||||
return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# NFOGuard Production Docker Compose - 3-Container Architecture
|
||||
#
|
||||
# RECOMMENDED SETUP: Separated core processing and web interface for optimal performance
|
||||
#
|
||||
# This is the default configuration providing:
|
||||
# - Performance isolation between web and processing
|
||||
# - Webhook responsiveness during scans
|
||||
# - Independent scaling and updates
|
||||
# - Professional web interface with branding
|
||||
#
|
||||
# For legacy single-container setup, see: docker-compose.legacy-single.yml
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
nfoguard-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: nfoguard-db
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_EXTERNAL_PORT:-5432}:5432" # Optional external access
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-nfoguard} -d ${DB_NAME:-nfoguard}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
# NFOGuard Core (Processing Engine)
|
||||
nfoguard:
|
||||
image: sbcrumb/nfoguard:latest
|
||||
container_name: nfoguard-core
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- WEB_EXTERNAL_PORT=${WEB_EXTERNAL_PORT}
|
||||
volumes:
|
||||
# Media paths (adjust to your setup)
|
||||
- /mnt/unionfs/Media/TV:/media/TV:ro
|
||||
- /mnt/unionfs/Media/Movies:/media/Movies:ro
|
||||
# Data persistence
|
||||
- nfoguard_data:/app/data
|
||||
# Logs
|
||||
- nfoguard_logs:/app/data/logs
|
||||
ports:
|
||||
- "${CORE_API_PORT:-8080}:8080" # Core API (webhooks, processing)
|
||||
depends_on:
|
||||
nfoguard-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
# NFOGuard Web Interface
|
||||
nfoguard-web:
|
||||
image: sbcrumb/nfoguard:latest # Same image as core!
|
||||
container_name: nfoguard-web
|
||||
restart: unless-stopped
|
||||
command: ["python", "start_web.py"] # Different entry point
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- TZ=${TZ:-America/New_York}
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8081}:8081" # Web Interface
|
||||
depends_on:
|
||||
nfoguard-db:
|
||||
condition: service_healthy
|
||||
nfoguard:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
nfoguard_data:
|
||||
driver: local
|
||||
nfoguard_logs:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
nfoguard-network:
|
||||
driver: bridge
|
||||
|
||||
# Configuration Notes:
|
||||
# 1. Core Processing (nfoguard): Handles webhooks, scanning, NFO management
|
||||
# 2. Web Interface (nfoguard-web): Lightweight dashboard and management
|
||||
# 3. Database (nfoguard-db): Shared PostgreSQL database
|
||||
#
|
||||
# Port Configuration:
|
||||
# - Core API: ${CORE_API_PORT:-8080} (webhooks, processing)
|
||||
# - Web Interface: ${WEB_API_PORT:-8081} (dashboard)
|
||||
# - Database: ${DB_EXTERNAL_PORT:-5432} (optional external access)
|
||||
#
|
||||
# Performance Benefits:
|
||||
# - Web interface operations don't impact core processing
|
||||
# - Webhooks remain responsive during long scans
|
||||
# - Independent scaling and resource allocation
|
||||
# - Separated concerns for maintenance and updates
|
||||
@@ -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:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web Interface Package
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web API Package
|
||||
@@ -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,142 @@
|
||||
"""
|
||||
NFOGuard Web Interface - Separated Web Application
|
||||
Lightweight FastAPI application for web interface only
|
||||
"""
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Add current directory and parent directory to path for imports
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
# Import web-specific configuration
|
||||
from config.web_settings import web_config
|
||||
|
||||
# Import database (lightweight, read-only access)
|
||||
from core.web_database import WebDatabase
|
||||
|
||||
# Import web routes and authentication
|
||||
from api.web_routes import register_web_routes
|
||||
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
"""Create FastAPI web application"""
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.6.12-web",
|
||||
docs_url="/docs" if web_config.web_debug else None,
|
||||
redoc_url="/redoc" if web_config.web_debug else None
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def initialize_web_database() -> WebDatabase:
|
||||
"""Initialize web database connection (read-only optimized)"""
|
||||
return WebDatabase(
|
||||
db_type=web_config.db_type,
|
||||
host=web_config.db_host,
|
||||
port=web_config.db_port,
|
||||
database=web_config.db_name,
|
||||
user=web_config.db_user,
|
||||
password=web_config.db_password
|
||||
)
|
||||
|
||||
|
||||
def setup_static_files(app: FastAPI) -> None:
|
||||
"""Mount static file directories"""
|
||||
# Mount main static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Mount logo separately for easy access
|
||||
app.mount("/logo", StaticFiles(directory="logo"), name="logo")
|
||||
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
def setup_signal_handlers():
|
||||
"""Setup graceful shutdown signal handlers"""
|
||||
def signal_handler(signum, frame):
|
||||
print(f"\n🛑 Received signal {signum}, shutting down web interface...")
|
||||
# Web interface can shutdown immediately (no background processing)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for NFOGuard Web Interface"""
|
||||
print("🌐 Starting NFOGuard Web Interface...")
|
||||
print(f"📊 Configuration: Port {web_config.web_port}, Auth: {'Enabled' if web_config.web_auth_enabled else 'Disabled'}")
|
||||
|
||||
# Setup signal handlers
|
||||
setup_signal_handlers()
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_web_app()
|
||||
|
||||
# Initialize database
|
||||
try:
|
||||
db = initialize_web_database()
|
||||
print(f"✅ Connected to database: {web_config.db_host}:{web_config.db_port}/{web_config.db_name}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create dependencies for dependency injection
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": web_config
|
||||
}
|
||||
|
||||
# Add authentication dependencies if enabled
|
||||
if web_config.web_auth_enabled:
|
||||
auth_deps = create_auth_dependencies(web_config)
|
||||
dependencies.update(auth_deps)
|
||||
|
||||
# Add authentication middleware
|
||||
app.add_middleware(SimpleAuthMiddleware, config=web_config)
|
||||
print(f"🔐 Web authentication enabled for user: {web_config.web_auth_username}")
|
||||
else:
|
||||
print("🔓 Web authentication disabled - interface is public")
|
||||
|
||||
# Setup static files and routes
|
||||
setup_static_files(app)
|
||||
|
||||
# Register web routes
|
||||
register_web_routes(app, dependencies)
|
||||
|
||||
print(f"🚀 Starting web server on {web_config.web_host}:{web_config.web_port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=web_config.web_host,
|
||||
port=web_config.web_port,
|
||||
workers=web_config.web_workers,
|
||||
log_level="debug" if web_config.web_debug else "info",
|
||||
access_log=web_config.web_debug
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Web interface shutdown by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Web interface failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,889 @@
|
||||
/* NFOGuard Web Interface Styles */
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--dark-color: #343a40;
|
||||
--light-color: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
--text-muted: #6c757d;
|
||||
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--dark-color);
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Header Logo and Text Layout */
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-logo .logo {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
/* Clean display for new logo */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header-content h1 i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content p {
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive logo layout */
|
||||
@media (max-width: 768px) {
|
||||
.header-logo {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-logo .logo {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Authentication Status */
|
||||
.auth-status {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-logout {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
|
||||
.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-info p {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-info small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--light-color);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Content Header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
color: var(--dark-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning-color);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--light-color);
|
||||
font-weight: 600;
|
||||
color: var(--dark-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
}
|
||||
|
||||
.data-table .loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination .btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.pagination .page-info {
|
||||
margin: 0 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Higher z-index for edit modals that appear on top of other modals */
|
||||
#edit-modal, #smart-fix-modal {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
/* Reports */
|
||||
.report-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.summary-card p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.report-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
/* Tools */
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tool-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.tool-card p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-display {
|
||||
background: var(--light-color);
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 300px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left-color: var(--success-color);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left-color: var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smart Fix Modal */
|
||||
.smart-fix-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.option-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.option-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option-label input[type="radio"] {
|
||||
margin-right: 0.75rem;
|
||||
margin-top: 0.1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.option-content h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--dark-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.option-content p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.option-content small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.manual-date-input {
|
||||
width: 100% !important;
|
||||
margin-top: 0.5rem !important;
|
||||
}
|
||||
|
||||
.option-card input[type="radio"]:checked + .option-content {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.option-card:has(input[type="radio"]:checked) {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Additional badge styles */
|
||||
.badge-info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
/* Enhanced Edit Modal Date Options */
|
||||
.date-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.date-option-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-option-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.date-option-label {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.date-option-label input[type="radio"] {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.date-option-content h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--dark-color);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-option-content p {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.date-option-content small {
|
||||
color: var(--primary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-option-card input[type="radio"]:checked + .date-option-content h4 {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.date-option-card:has(input[type="radio"]:checked) {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 123, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.content-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-center { text-align: center; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.d-none { display: none; }
|
||||
.d-block { display: block; }
|
||||
.d-flex { display: flex; }
|
||||
.justify-content-between { justify-content: space-between; }
|
||||
.align-items-center { align-items: center; }
|
||||
@@ -0,0 +1,462 @@
|
||||
<!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">
|
||||
<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">
|
||||
<!-- Scan Status Banner -->
|
||||
<div class="scan-status-banner" id="dashboard-scan-status" style="display: none;">
|
||||
<div class="scan-status-content">
|
||||
<div class="scan-status-icon">
|
||||
<i class="fas fa-sync fa-spin"></i>
|
||||
</div>
|
||||
<div class="scan-status-info">
|
||||
<h4>Scan in Progress</h4>
|
||||
<p id="dashboard-scan-text">Processing media files...</p>
|
||||
<div class="scan-progress-mini">
|
||||
<div class="progress-bar-mini">
|
||||
<div class="progress-fill-mini" id="dashboard-scan-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Scan Status Display -->
|
||||
<div class="scan-status-card" id="scan-status" style="display: none;">
|
||||
<div class="scan-status-header">
|
||||
<h3><i class="fas fa-sync fa-spin"></i> Scan in Progress</h3>
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress"></div>
|
||||
</div>
|
||||
<span class="progress-text" id="scan-progress-text">Initializing...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools-grid">
|
||||
<!-- Manual Scan Tools -->
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-search"></i> Manual Scan</h3>
|
||||
<p>Run manual scans on your media library</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">Both (Movies & TV)</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
<option value="tv">TV Series Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Mode:</label>
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Items Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<h3><i class="fas fa-folder"></i> Custom Directory Scan</h3>
|
||||
<p>Scan a specific directory or path</p>
|
||||
<form id="custom-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Directory Path:</label>
|
||||
<input type="text" id="scan-path" placeholder="/media/Movies/specific-folder" />
|
||||
<small>Enter the full path to scan (will be auto-formatted)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="custom-scan-type" required>
|
||||
<option value="both">Auto-detect</option>
|
||||
<option value="movies">Movies</option>
|
||||
<option value="tv">TV Series</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-search"></i> Scan Directory
|
||||
</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
@@ -387,9 +387,6 @@ class MovieProcessor:
|
||||
|
||||
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
|
||||
|
||||
# Yield control briefly during movie processing to allow web interface requests
|
||||
import time
|
||||
time.sleep(0.005) # 5ms yield per movie to improve responsiveness
|
||||
|
||||
# Save to database
|
||||
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
|
||||
|
||||
@@ -214,11 +214,6 @@ class TVProcessor:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
|
||||
# Yield control every 3 episodes to allow other requests (webhooks, web interface)
|
||||
if episode_count % 3 == 0:
|
||||
import time
|
||||
time.sleep(0.1) # 100ms yield to improve responsiveness during episode processing
|
||||
_log("DEBUG", f"Processed {episode_count} episodes, yielding to allow other requests...")
|
||||
|
||||
# Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
|
||||
pass
|
||||
@@ -453,11 +448,6 @@ class TVProcessor:
|
||||
episode_data['dateAdded'] = import_date
|
||||
_log("DEBUG", f"Got import date from history for S{season:02d}E{episode_num:02d}: {import_date}")
|
||||
|
||||
# Yield control every 5 API calls to allow other requests
|
||||
if api_calls_made % 5 == 0:
|
||||
import time
|
||||
time.sleep(0.01) # 10ms yield to other processes
|
||||
_log("DEBUG", f"Yielded after {api_calls_made} Sonarr API calls to allow other requests...")
|
||||
|
||||
# Fallback to episodeFile.dateAdded if history didn't work
|
||||
if not episode_data['dateAdded'] and episode.get('hasFile'):
|
||||
@@ -468,11 +458,6 @@ class TVProcessor:
|
||||
|
||||
episode_map[(season, episode_num)] = episode_data
|
||||
|
||||
# Also yield control every 20 episodes processed to prevent blocking
|
||||
if episodes_processed % 20 == 0:
|
||||
import time
|
||||
time.sleep(0.01) # 10ms yield for large episode lists
|
||||
_log("DEBUG", f"Processed {episodes_processed} episodes, yielding to allow other requests...")
|
||||
|
||||
if filter_set:
|
||||
_log("DEBUG", f"Made {api_calls_made} Sonarr history API calls for filtered episodes (instead of all episodes)")
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFOGuard Web Interface Starter
|
||||
Simple script to start web interface using existing config system
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Import existing configuration
|
||||
from config.settings import config
|
||||
|
||||
# Import existing database and components
|
||||
from core.database import NFOGuardDatabase
|
||||
|
||||
# Import web routes from existing system
|
||||
from api.web_routes import register_web_routes
|
||||
|
||||
# Import authentication system
|
||||
from api.auth import SimpleAuthMiddleware, AuthSession
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
"""Create FastAPI web application"""
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.7.0-web",
|
||||
docs_url=None, # Disable docs in production
|
||||
redoc_url=None
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def setup_static_files(app: FastAPI) -> None:
|
||||
"""Mount static file directories"""
|
||||
static_path = os.path.join(os.path.dirname(__file__), "nfoguard-web", "static")
|
||||
logo_path = os.path.join(os.path.dirname(__file__), "logo")
|
||||
|
||||
print(f"🔍 Checking static path: {static_path} (exists: {os.path.exists(static_path)})")
|
||||
print(f"🔍 Checking logo path: {logo_path} (exists: {os.path.exists(logo_path)})")
|
||||
|
||||
if os.path.exists(static_path):
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
print(f"✅ Mounted static files from: {static_path}")
|
||||
else:
|
||||
print(f"❌ Static path not found: {static_path}")
|
||||
|
||||
if os.path.exists(logo_path):
|
||||
app.mount("/logo", StaticFiles(directory=logo_path), name="logo")
|
||||
print(f"✅ Mounted logo files from: {logo_path}")
|
||||
else:
|
||||
print(f"❌ Logo path not found: {logo_path}")
|
||||
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
index_file = os.path.join(static_path, "index.html")
|
||||
if os.path.exists(index_file):
|
||||
return FileResponse(index_file)
|
||||
else:
|
||||
return {"message": "NFOGuard Web Interface", "status": "running"}
|
||||
|
||||
# Serve favicon
|
||||
@app.get("/favicon.ico")
|
||||
async def serve_favicon():
|
||||
# Try to serve favicon from logo directory or static files
|
||||
favicon_paths = [
|
||||
os.path.join(logo_path, "favicon.ico"),
|
||||
os.path.join(static_path, "favicon.ico"),
|
||||
os.path.join(logo_path, "NFOGuardLogo.png") # Fallback to new logo
|
||||
]
|
||||
|
||||
for favicon_path in favicon_paths:
|
||||
if os.path.exists(favicon_path):
|
||||
return FileResponse(favicon_path)
|
||||
|
||||
# Return 204 No Content if no favicon found
|
||||
from fastapi import Response
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for NFOGuard Web Interface"""
|
||||
print("🌐 Starting NFOGuard Web Interface...")
|
||||
|
||||
# Use existing config system
|
||||
web_host = os.environ.get("WEB_HOST", "0.0.0.0")
|
||||
web_port = int(os.environ.get("WEB_PORT", "8081"))
|
||||
|
||||
print(f"📊 Configuration: Port {web_port}")
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_web_app()
|
||||
|
||||
# Initialize database using existing system
|
||||
try:
|
||||
db = NFOGuardDatabase(config)
|
||||
print(f"✅ Connected to database: {config.db_host}:{config.db_port}/{config.db_name}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup authentication if enabled
|
||||
auth_enabled = getattr(config, 'web_auth_enabled', False)
|
||||
session_manager = None
|
||||
|
||||
if auth_enabled:
|
||||
session_timeout = getattr(config, 'web_auth_session_timeout', 3600)
|
||||
session_manager = AuthSession(timeout_seconds=session_timeout)
|
||||
print(f"🔐 Web authentication enabled (session timeout: {session_timeout}s)")
|
||||
else:
|
||||
print("🌐 Web authentication disabled")
|
||||
|
||||
# Create dependencies for dependency injection (simplified for web-only)
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": config,
|
||||
"nfo_manager": None, # Not needed for read-only web interface
|
||||
"movie_processor": None, # Not needed for read-only web interface
|
||||
"tv_processor": None, # Not needed for read-only web interface
|
||||
"auth_enabled": auth_enabled,
|
||||
"session_manager": session_manager
|
||||
}
|
||||
|
||||
# Add authentication middleware if enabled (BEFORE routes)
|
||||
if auth_enabled:
|
||||
# Pass the session manager to middleware so it uses the same instance
|
||||
app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager)
|
||||
print("🔐 Authentication middleware added to web interface")
|
||||
|
||||
# Setup static files and routes
|
||||
setup_static_files(app)
|
||||
|
||||
# Register web routes
|
||||
register_web_routes(app, dependencies)
|
||||
|
||||
print(f"🚀 Starting web server on {web_host}:{web_port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=web_host,
|
||||
port=web_port,
|
||||
workers=1,
|
||||
log_level="info",
|
||||
access_log=False
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Web interface shutdown by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Web interface failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -196,7 +196,12 @@ class WebhookBatcher:
|
||||
|
||||
# Shutdown the thread pool executor
|
||||
try:
|
||||
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
|
||||
# Use timeout parameter only if supported (Python 3.9+)
|
||||
import sys
|
||||
if sys.version_info >= (3, 9):
|
||||
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
|
||||
else:
|
||||
self.executor.shutdown(wait=True) # No timeout for older Python versions
|
||||
_log("INFO", "Thread pool executor shut down successfully")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Error shutting down thread pool: {e}")
|
||||
|
||||
Reference in New Issue
Block a user