feat: Add optional web interface authentication with session management #58
@@ -135,6 +135,15 @@ PATH_DEBUG=false
|
|||||||
# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
|
# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
|
||||||
SUPPRESS_TVDB_WARNINGS=true
|
SUPPRESS_TVDB_WARNINGS=true
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# WEB INTERFACE AUTHENTICATION
|
||||||
|
# ===========================================
|
||||||
|
# Enable web interface authentication (default: false)
|
||||||
|
WEB_AUTH_ENABLED=false
|
||||||
|
|
||||||
|
# Session timeout in seconds (default: 3600 = 1 hour)
|
||||||
|
WEB_AUTH_SESSION_TIMEOUT=3600
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# SERVER CONFIGURATION
|
# SERVER CONFIGURATION
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -39,3 +39,11 @@ JELLYSEERR_API_KEY=your_jellyseerr_api_key
|
|||||||
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
|
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
|
||||||
# Get free API key at: https://thetvdb.com/api-information
|
# Get free API key at: https://thetvdb.com/api-information
|
||||||
TVDB_API_KEY=your_tvdb_api_key
|
TVDB_API_KEY=your_tvdb_api_key
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# WEB INTERFACE AUTHENTICATION (OPTIONAL)
|
||||||
|
# ===========================================
|
||||||
|
# Web interface login credentials (only used if WEB_AUTH_ENABLED=true in .env)
|
||||||
|
# Set WEB_AUTH_ENABLED=true in .env file to enable authentication
|
||||||
|
WEB_AUTH_USERNAME=admin
|
||||||
|
WEB_AUTH_PASSWORD=your_secure_web_password
|
||||||
@@ -8,17 +8,11 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
|
|
||||||
>
|
|
||||||
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
|
|
||||||
>
|
|
||||||
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
||||||
>
|
>
|
||||||
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users:
|
> **💬 Community Feedback**: Join our Discord if you'd like to share feedback, test new features, or discuss improvements with other users:
|
||||||
>
|
>
|
||||||
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
|
> **[Join Discord: https://discord.gg/ZykJRGt72b](https://discord.gg/ZykJRGt72b)**
|
||||||
>
|
|
||||||
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,7 +32,7 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d
|
|||||||
- **Batch Processing** - Efficient handling of multiple files simultaneously
|
- **Batch Processing** - Efficient handling of multiple files simultaneously
|
||||||
- **PostgreSQL Database** - Production-ready database with optimized queries
|
- **PostgreSQL Database** - Production-ready database with optimized queries
|
||||||
- **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans
|
- **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans
|
||||||
- **88% Scan Optimization** - TV library scans reduced from hours to minutes
|
- **88% Scan Optimization** - Subsequent scans reduced from hours to minutes via smart skip logic
|
||||||
|
|
||||||
### **Web Interface & Management**
|
### **Web Interface & Management**
|
||||||
- **Complete Web UI** - Episode and movie management with filtering and search
|
- **Complete Web UI** - Episode and movie management with filtering and search
|
||||||
@@ -64,9 +58,9 @@ NFOGuard automatically updates movie and TV show NFO files with proper release d
|
|||||||
### 1. Download Configuration Files
|
### 1. Download Configuration Files
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template
|
wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/.env.template
|
||||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template
|
wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/.env.secrets.template
|
||||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml
|
wget https://raw.githubusercontent.com/sbcrumb/nfoguard/main/docker-compose.example.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure Environment
|
### 2. Configure Environment
|
||||||
@@ -149,10 +143,10 @@ image: sbcrumb/nfoguard:dev
|
|||||||
|
|
||||||
### Specific Version
|
### Specific Version
|
||||||
```yaml
|
```yaml
|
||||||
image: sbcrumb/nfoguard:v2.0.0 # Latest with monitoring & validation
|
image: sbcrumb/nfoguard:v2.6.7 # Latest with PostgreSQL & optimization
|
||||||
```
|
```
|
||||||
|
|
||||||
> **🚀 Version 2.0.0** includes major architecture improvements, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
|
> **🚀 Version 2.6.7** includes major architecture improvements, PostgreSQL database migration, 88% scan optimization, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
|
||||||
|
|
||||||
## 🔗 Webhook Setup
|
## 🔗 Webhook Setup
|
||||||
|
|
||||||
@@ -499,7 +493,7 @@ NFOGuard will ignore:
|
|||||||
|
|
||||||
## 🆘 Support
|
## 🆘 Support
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
|
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/nfoguard/issues)
|
||||||
- **Documentation**: See `SETUP.md` for detailed instructions
|
- **Documentation**: See `SETUP.md` for detailed instructions
|
||||||
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
|
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
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",
|
||||||
|
"/database/" # Database management endpoints
|
||||||
|
]
|
||||||
|
|
||||||
|
# Routes that are always public (webhooks, health checks)
|
||||||
|
self.public_routes = [
|
||||||
|
"/webhook/",
|
||||||
|
"/health",
|
||||||
|
"/ping",
|
||||||
|
"/api/v1/health",
|
||||||
|
"/api/v1/metrics"
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-1
@@ -7,7 +7,7 @@ import requests
|
|||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import HTTPException, BackgroundTasks, Request
|
from fastapi import HTTPException, BackgroundTasks, Request, Response
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
@@ -1396,6 +1396,38 @@ def register_routes(app, dependencies: dict):
|
|||||||
"""Update episode dateadded"""
|
"""Update episode dateadded"""
|
||||||
return await update_episode_date(dependencies, imdb_id, season, episode, request.dateadded, request.source)
|
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")
|
@app.post("/api/bulk/update-source")
|
||||||
async def _bulk_update_source(request: BulkUpdateRequest):
|
async def _bulk_update_source(request: BulkUpdateRequest):
|
||||||
"""Bulk update source for movies or episodes"""
|
"""Bulk update source for movies or episodes"""
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ class NFOGuardConfig:
|
|||||||
# TV processing
|
# TV processing
|
||||||
self._load_tv_settings()
|
self._load_tv_settings()
|
||||||
|
|
||||||
|
# Web interface authentication
|
||||||
|
self._load_auth_settings()
|
||||||
|
|
||||||
def _load_paths(self) -> None:
|
def _load_paths(self) -> None:
|
||||||
"""Load and validate path configuration"""
|
"""Load and validate path configuration"""
|
||||||
tv_paths_env = os.environ.get("TV_PATHS", "")
|
tv_paths_env = os.environ.get("TV_PATHS", "")
|
||||||
@@ -156,6 +159,13 @@ class NFOGuardConfig:
|
|||||||
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
|
||||||
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
|
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
|
||||||
|
|
||||||
|
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 = self._get_int_env("WEB_AUTH_SESSION_TIMEOUT", 3600, 300, 86400) # 1 hour default, 5min-24h range
|
||||||
|
|
||||||
def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int:
|
def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int:
|
||||||
"""Get integer environment variable with validation"""
|
"""Get integer environment variable with validation"""
|
||||||
value_str = os.environ.get(name)
|
value_str = os.environ.get(name)
|
||||||
|
|||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug script to check specific TV series/episode data in NFOGuard database
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the project root to the path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from core.database import NFOGuardDatabase
|
||||||
|
|
||||||
|
def debug_series(imdb_id: str):
|
||||||
|
"""Debug a specific TV series' data"""
|
||||||
|
print(f"📺 DEBUG TV SERIES: {imdb_id}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db = NFOGuardDatabase()
|
||||||
|
|
||||||
|
# Get series episodes
|
||||||
|
episodes = db.get_series_episodes(imdb_id)
|
||||||
|
if not episodes:
|
||||||
|
print(f"❌ TV series {imdb_id} not found in database")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📊 SERIES OVERVIEW:")
|
||||||
|
print(f" IMDb ID: {imdb_id}")
|
||||||
|
print(f" Total Episodes: {len(episodes)}")
|
||||||
|
|
||||||
|
# Count episodes by status
|
||||||
|
with_dates = sum(1 for ep in episodes if ep.get('dateadded'))
|
||||||
|
without_dates = len(episodes) - with_dates
|
||||||
|
with_video = sum(1 for ep in episodes if ep.get('has_video_file'))
|
||||||
|
|
||||||
|
print(f" Episodes with dates: {with_dates}")
|
||||||
|
print(f" Episodes without dates: {without_dates}")
|
||||||
|
print(f" Episodes with video files: {with_video}")
|
||||||
|
|
||||||
|
# Group by season
|
||||||
|
seasons = {}
|
||||||
|
for ep in episodes:
|
||||||
|
season = ep.get('season', 'Unknown')
|
||||||
|
if season not in seasons:
|
||||||
|
seasons[season] = []
|
||||||
|
seasons[season].append(ep)
|
||||||
|
|
||||||
|
print(f" Seasons: {len(seasons)} ({', '.join(f'S{s}' if isinstance(s, int) else str(s) for s in sorted(seasons.keys()))})")
|
||||||
|
|
||||||
|
# Show sources breakdown
|
||||||
|
sources = {}
|
||||||
|
for ep in episodes:
|
||||||
|
source = ep.get('source', 'None')
|
||||||
|
sources[source] = sources.get(source, 0) + 1
|
||||||
|
|
||||||
|
print(f"\n📈 SOURCES BREAKDOWN:")
|
||||||
|
for source, count in sorted(sources.items(), key=lambda x: x[1], reverse=True):
|
||||||
|
print(f" {source}: {count} episodes")
|
||||||
|
|
||||||
|
# Show recent episodes (last 10 by date added)
|
||||||
|
episodes_with_dates = [ep for ep in episodes if ep.get('dateadded')]
|
||||||
|
recent_episodes = sorted(episodes_with_dates, key=lambda x: x.get('last_updated', ''), reverse=True)[:10]
|
||||||
|
|
||||||
|
if recent_episodes:
|
||||||
|
print(f"\n🕒 RECENT EPISODES (by last_updated):")
|
||||||
|
for ep in recent_episodes:
|
||||||
|
season = ep.get('season', '?')
|
||||||
|
episode = ep.get('episode', '?')
|
||||||
|
dateadded = ep.get('dateadded', 'None')
|
||||||
|
source = ep.get('source', 'None')
|
||||||
|
video = "✅" if ep.get('has_video_file') else "❌"
|
||||||
|
print(f" S{season:02d}E{episode:02d}: {dateadded} | {source} | Video: {video}")
|
||||||
|
|
||||||
|
def debug_episode(imdb_id: str, season: int, episode: int):
|
||||||
|
"""Debug a specific episode's data"""
|
||||||
|
print(f"📺 DEBUG TV EPISODE: {imdb_id} S{season:02d}E{episode:02d}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db = NFOGuardDatabase()
|
||||||
|
|
||||||
|
# Get specific episode
|
||||||
|
episode_data = db.get_episode_date(imdb_id, season, episode)
|
||||||
|
if not episode_data:
|
||||||
|
print(f"❌ Episode S{season:02d}E{episode:02d} for series {imdb_id} not found in database")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("📊 RAW EPISODE DATA:")
|
||||||
|
for key, value in episode_data.items():
|
||||||
|
print(f" {key}: {repr(value)}")
|
||||||
|
|
||||||
|
print("\n📺 FORMATTED EPISODE DATA:")
|
||||||
|
print(f" Series IMDb: {episode_data.get('imdb_id', 'Unknown')}")
|
||||||
|
print(f" Season/Episode: S{episode_data.get('season', '?'):02d}E{episode_data.get('episode', '?'):02d}")
|
||||||
|
print(f" Title: {episode_data.get('title', 'Unknown')}")
|
||||||
|
print(f" Air Date: {episode_data.get('air_date', 'None')}")
|
||||||
|
print(f" Date Added: {episode_data.get('dateadded', 'None')}")
|
||||||
|
print(f" Source: {episode_data.get('source', 'None')}")
|
||||||
|
print(f" Has Video: {episode_data.get('has_video_file', False)}")
|
||||||
|
print(f" Video Path: {episode_data.get('video_path', 'None')}")
|
||||||
|
print(f" Last Updated: {episode_data.get('last_updated', 'None')}")
|
||||||
|
|
||||||
|
# Check if air date is valid
|
||||||
|
air_date = episode_data.get('air_date')
|
||||||
|
if air_date and air_date.strip():
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
test_date = f"{air_date}T00:00:00"
|
||||||
|
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
|
||||||
|
print(f"\n✅ Air date is valid: {parsed}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Air date is INVALID: {e}")
|
||||||
|
else:
|
||||||
|
print(f"\n⚠️ Air date is empty or None")
|
||||||
|
|
||||||
|
# Check if dateadded is valid
|
||||||
|
dateadded = episode_data.get('dateadded')
|
||||||
|
if dateadded and dateadded.strip():
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
if isinstance(dateadded, str):
|
||||||
|
test_date = f"{dateadded}T00:00:00" if 'T' not in dateadded else dateadded
|
||||||
|
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
parsed = dateadded
|
||||||
|
print(f"✅ Date added is valid: {parsed}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Date added is INVALID: {e}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Date added is empty or None")
|
||||||
|
|
||||||
|
def debug_season(imdb_id: str, season: int):
|
||||||
|
"""Debug all episodes in a specific season"""
|
||||||
|
print(f"📺 DEBUG TV SEASON: {imdb_id} Season {season}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db = NFOGuardDatabase()
|
||||||
|
|
||||||
|
# Get series episodes
|
||||||
|
all_episodes = db.get_series_episodes(imdb_id)
|
||||||
|
season_episodes = [ep for ep in all_episodes if ep.get('season') == season]
|
||||||
|
|
||||||
|
if not season_episodes:
|
||||||
|
print(f"❌ No episodes found for season {season} of series {imdb_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📊 SEASON {season} OVERVIEW:")
|
||||||
|
print(f" Total Episodes: {len(season_episodes)}")
|
||||||
|
|
||||||
|
# Sort by episode number
|
||||||
|
season_episodes.sort(key=lambda x: x.get('episode', 0))
|
||||||
|
|
||||||
|
with_dates = sum(1 for ep in season_episodes if ep.get('dateadded'))
|
||||||
|
without_dates = len(season_episodes) - with_dates
|
||||||
|
with_video = sum(1 for ep in season_episodes if ep.get('has_video_file'))
|
||||||
|
|
||||||
|
print(f" Episodes with dates: {with_dates}")
|
||||||
|
print(f" Episodes without dates: {without_dates}")
|
||||||
|
print(f" Episodes with video files: {with_video}")
|
||||||
|
|
||||||
|
print(f"\n📋 EPISODE LIST:")
|
||||||
|
for ep in season_episodes:
|
||||||
|
episode_num = ep.get('episode', '?')
|
||||||
|
title = ep.get('title', 'Unknown')[:30] + ('...' if len(ep.get('title', '')) > 30 else '')
|
||||||
|
dateadded = ep.get('dateadded', 'None')
|
||||||
|
source = ep.get('source', 'None')
|
||||||
|
video = "✅" if ep.get('has_video_file') else "❌"
|
||||||
|
air_date = ep.get('air_date', 'None')
|
||||||
|
|
||||||
|
print(f" E{episode_num:02d}: {title:<33} | Added: {dateadded} | Air: {air_date} | Video: {video}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage:")
|
||||||
|
print(" python debug_tv.py <imdb_id> # Debug entire series")
|
||||||
|
print(" python debug_tv.py <imdb_id> <season> # Debug specific season")
|
||||||
|
print(" python debug_tv.py <imdb_id> <season> <episode> # Debug specific episode")
|
||||||
|
print("\nExamples:")
|
||||||
|
print(" python debug_tv.py tt0121955 # Debug South Park")
|
||||||
|
print(" python debug_tv.py tt0121955 27 # Debug South Park Season 27")
|
||||||
|
print(" python debug_tv.py tt0121955 27 6 # Debug South Park S27E06")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
imdb_id = sys.argv[1]
|
||||||
|
if not imdb_id.startswith('tt'):
|
||||||
|
imdb_id = f'tt{imdb_id}'
|
||||||
|
|
||||||
|
if len(sys.argv) == 4:
|
||||||
|
# Debug specific episode
|
||||||
|
season = int(sys.argv[2])
|
||||||
|
episode = int(sys.argv[3])
|
||||||
|
debug_episode(imdb_id, season, episode)
|
||||||
|
elif len(sys.argv) == 3:
|
||||||
|
# Debug specific season
|
||||||
|
season = int(sys.argv[2])
|
||||||
|
debug_season(imdb_id, season)
|
||||||
|
else:
|
||||||
|
# Debug entire series
|
||||||
|
debug_series(imdb_id)
|
||||||
@@ -15,6 +15,9 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
# Import configuration first
|
# Import configuration first
|
||||||
from config.settings import config
|
from config.settings import config
|
||||||
|
|
||||||
|
# Import authentication
|
||||||
|
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
|
||||||
from utils.logging import _log
|
from utils.logging import _log
|
||||||
|
|
||||||
# Import core components
|
# Import core components
|
||||||
@@ -172,6 +175,17 @@ def main():
|
|||||||
# Initialize components
|
# Initialize components
|
||||||
dependencies = 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")
|
||||||
|
|
||||||
# Store dependencies globally for signal handler access
|
# Store dependencies globally for signal handler access
|
||||||
signal_handler.dependencies = dependencies
|
signal_handler.dependencies = dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ body {
|
|||||||
color: white;
|
color: white;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
@@ -63,6 +64,45 @@ body {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.nav-tabs {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 1rem auto 0;
|
margin: 1rem auto 0;
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
|
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
|
||||||
<p>Database Management & Reporting</p>
|
<p>Database Management & Reporting</p>
|
||||||
</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">
|
<nav class="nav-tabs">
|
||||||
<button class="nav-tab active" data-tab="dashboard">
|
<button class="nav-tab active" data-tab="dashboard">
|
||||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ let dashboardData = null;
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initializeTabs();
|
initializeTabs();
|
||||||
initializeEventListeners();
|
initializeEventListeners();
|
||||||
|
checkAuthStatus(); // Check authentication status on page load
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
loadSeriesSources();
|
loadSeriesSources();
|
||||||
});
|
});
|
||||||
@@ -1376,3 +1377,61 @@ function updateEpisodeModalCounts() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// Authentication Functions
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
async function checkAuthStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/status');
|
||||||
|
const authStatus = await response.json();
|
||||||
|
|
||||||
|
const authStatusDiv = document.getElementById('auth-status');
|
||||||
|
const authUsernameSpan = document.getElementById('auth-username');
|
||||||
|
|
||||||
|
if (authStatus.auth_enabled && authStatus.authenticated) {
|
||||||
|
// Show authentication status with username
|
||||||
|
authUsernameSpan.textContent = authStatus.username;
|
||||||
|
authStatusDiv.style.display = 'flex';
|
||||||
|
} else if (authStatus.auth_enabled && !authStatus.authenticated) {
|
||||||
|
// This shouldn't happen if middleware is working, but handle it
|
||||||
|
console.warn('Auth enabled but not authenticated - middleware may be misconfigured');
|
||||||
|
} else {
|
||||||
|
// Authentication disabled - hide auth status
|
||||||
|
authStatusDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check authentication status:', error);
|
||||||
|
// Hide auth status on error
|
||||||
|
document.getElementById('auth-status').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
if (!confirm('Are you sure you want to logout?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✅ Logged out successfully', 'success');
|
||||||
|
// Reload page to trigger authentication prompt
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showToast('❌ Logout failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error);
|
||||||
|
showToast('❌ Logout error', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user