Merge branch 'dev'
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
@@ -184,8 +178,44 @@ curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
|
|||||||
|
|
||||||
# Bulk update all movies from Radarr database
|
# Bulk update all movies from Radarr database
|
||||||
curl -X POST "http://localhost:8080/bulk/update"
|
curl -X POST "http://localhost:8080/bulk/update"
|
||||||
|
|
||||||
|
# Verify NFO files match database data
|
||||||
|
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=both"
|
||||||
|
|
||||||
|
# Fix NFO files that don't match database
|
||||||
|
curl -X POST "http://localhost:8080/database/fix/nfo-sync?media_type=both"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### NFO Verification & Synchronization
|
||||||
|
|
||||||
|
NFOGuard includes comprehensive verification tools to ensure NFO files remain synchronized with database dates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all NFO files (movies and episodes)
|
||||||
|
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=both"
|
||||||
|
|
||||||
|
# Verify only movies
|
||||||
|
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=movies"
|
||||||
|
|
||||||
|
# Verify only TV episodes
|
||||||
|
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=episodes"
|
||||||
|
|
||||||
|
# Fix detected issues by regenerating NFO files
|
||||||
|
curl -X POST "http://localhost:8080/database/fix/nfo-sync?media_type=both"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification Checks:**
|
||||||
|
- **Missing NFO files** - Database entries without corresponding NFO files
|
||||||
|
- **Empty NFO files** - NFO files that exist but contain no content
|
||||||
|
- **Date mismatches** - Database dates don't match NFO file dates
|
||||||
|
- **Source mismatches** - Database source doesn't match NFO source information
|
||||||
|
|
||||||
|
**Fix Operations:**
|
||||||
|
- Regenerates NFO files from database data for any detected issues
|
||||||
|
- Preserves existing metadata while updating NFOGuard date sections
|
||||||
|
- Only operates when `MANAGE_NFO=true` is configured
|
||||||
|
- Creates proper movie.nfo and episode S##E##.nfo files
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
#### **Core Operations**
|
#### **Core Operations**
|
||||||
@@ -214,6 +244,16 @@ curl -X POST "http://localhost:8080/bulk/update"
|
|||||||
| `/api/v1/metrics/errors` | GET | Error metrics and recent failures |
|
| `/api/v1/metrics/errors` | GET | Error metrics and recent failures |
|
||||||
| `/api/v1/metrics/system` | GET | System resource metrics |
|
| `/api/v1/metrics/system` | GET | System resource metrics |
|
||||||
|
|
||||||
|
#### **Database Management**
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/database/backfill/movie-release-dates` | POST | Backfill missing release dates for existing movies |
|
||||||
|
| `/database/cleanup/orphaned-episodes` | POST | Delete episodes without video files |
|
||||||
|
| `/database/cleanup/orphaned-movies` | POST | Delete movies without video files |
|
||||||
|
| `/database/cleanup/orphaned-series` | POST | Delete TV series without directories |
|
||||||
|
| `/database/verify/nfo-sync` | POST | Verify that database dates match NFO file contents |
|
||||||
|
| `/database/fix/nfo-sync` | POST | Fix NFO sync issues by regenerating NFO files from database data |
|
||||||
|
|
||||||
#### **Configuration & Debugging**
|
#### **Configuration & Debugging**
|
||||||
| Endpoint | Method | Purpose |
|
| Endpoint | Method | Purpose |
|
||||||
|----------|--------|---------|
|
|----------|--------|---------|
|
||||||
@@ -499,7 +539,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)
|
||||||
|
|
||||||
|
|||||||
+182
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+548
-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
|
||||||
@@ -1233,6 +1233,509 @@ async def cleanup_orphaned_series(dependencies: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_nfo_sync(dependencies: dict, media_type: str = "both"):
|
||||||
|
"""Verify that database dates match NFO file contents"""
|
||||||
|
db = dependencies["db"]
|
||||||
|
nfo_manager = dependencies["nfo_manager"]
|
||||||
|
config = dependencies["config"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
verification_results = {
|
||||||
|
"movies": {"total": 0, "verified": 0, "missing_nfo": 0, "date_mismatch": 0, "empty_nfo": 0, "issues": []},
|
||||||
|
"episodes": {"total": 0, "verified": 0, "missing_nfo": 0, "date_mismatch": 0, "empty_nfo": 0, "issues": []}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"🔍 NFO VERIFICATION STARTED: Checking {media_type}")
|
||||||
|
|
||||||
|
# Verify Movies
|
||||||
|
if media_type in ["both", "movies"]:
|
||||||
|
print("📽️ Verifying movie NFO files...")
|
||||||
|
|
||||||
|
# Get all movies from database
|
||||||
|
with db.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT imdb_id, path, dateadded, released, source FROM movies WHERE has_video_file = true")
|
||||||
|
movies = cursor.fetchall()
|
||||||
|
|
||||||
|
verification_results["movies"]["total"] = len(movies)
|
||||||
|
|
||||||
|
for movie in movies:
|
||||||
|
imdb_id = movie['imdb_id']
|
||||||
|
db_path = movie['path']
|
||||||
|
db_dateadded = movie['dateadded']
|
||||||
|
db_released = movie['released']
|
||||||
|
db_source = movie['source']
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
movie_path = Path(db_path)
|
||||||
|
nfo_path = movie_path / "movie.nfo"
|
||||||
|
|
||||||
|
# Check if NFO exists
|
||||||
|
if not nfo_path.exists():
|
||||||
|
verification_results["movies"]["missing_nfo"] += 1
|
||||||
|
verification_results["movies"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"path": str(movie_path),
|
||||||
|
"issue": "missing_nfo",
|
||||||
|
"message": "NFO file does not exist"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if NFO is empty
|
||||||
|
nfo_content = nfo_path.read_text(encoding='utf-8').strip()
|
||||||
|
if not nfo_content:
|
||||||
|
verification_results["movies"]["empty_nfo"] += 1
|
||||||
|
verification_results["movies"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"path": str(movie_path),
|
||||||
|
"issue": "empty_nfo",
|
||||||
|
"message": "NFO file exists but is empty"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract NFOGuard data from NFO
|
||||||
|
nfo_data = nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||||
|
|
||||||
|
if not nfo_data:
|
||||||
|
verification_results["movies"]["date_mismatch"] += 1
|
||||||
|
verification_results["movies"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"path": str(movie_path),
|
||||||
|
"issue": "no_nfoguard_data",
|
||||||
|
"message": "NFO exists but contains no NFOGuard date information",
|
||||||
|
"db_dateadded": str(db_dateadded),
|
||||||
|
"db_source": db_source
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Compare dates
|
||||||
|
nfo_dateadded = nfo_data.get("dateadded")
|
||||||
|
nfo_source = nfo_data.get("source")
|
||||||
|
|
||||||
|
# Convert database datetime to string for comparison
|
||||||
|
if hasattr(db_dateadded, 'isoformat'):
|
||||||
|
db_dateadded_str = db_dateadded.isoformat()
|
||||||
|
else:
|
||||||
|
db_dateadded_str = str(db_dateadded)
|
||||||
|
|
||||||
|
# Check for date mismatch
|
||||||
|
if nfo_dateadded != db_dateadded_str or nfo_source != db_source:
|
||||||
|
verification_results["movies"]["date_mismatch"] += 1
|
||||||
|
verification_results["movies"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"path": str(movie_path),
|
||||||
|
"issue": "date_mismatch",
|
||||||
|
"message": "Database and NFO dates/sources don't match",
|
||||||
|
"db_dateadded": db_dateadded_str,
|
||||||
|
"db_source": db_source,
|
||||||
|
"nfo_dateadded": nfo_dateadded,
|
||||||
|
"nfo_source": nfo_source
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Everything matches
|
||||||
|
verification_results["movies"]["verified"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
verification_results["movies"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"path": db_path,
|
||||||
|
"issue": "verification_error",
|
||||||
|
"message": f"Error during verification: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Verify TV Episodes
|
||||||
|
if media_type in ["both", "episodes"]:
|
||||||
|
print("📺 Verifying TV episode NFO files...")
|
||||||
|
|
||||||
|
# Get all episodes from database
|
||||||
|
with db.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT imdb_id, season, episode, air_date, dateadded, source, video_path
|
||||||
|
FROM episodes
|
||||||
|
WHERE has_video_file = true
|
||||||
|
ORDER BY imdb_id, season, episode
|
||||||
|
""")
|
||||||
|
episodes = cursor.fetchall()
|
||||||
|
|
||||||
|
verification_results["episodes"]["total"] = len(episodes)
|
||||||
|
|
||||||
|
for episode in episodes:
|
||||||
|
imdb_id = episode['imdb_id']
|
||||||
|
season = episode['season']
|
||||||
|
episode_num = episode['episode']
|
||||||
|
db_air_date = episode['air_date']
|
||||||
|
db_dateadded = episode['dateadded']
|
||||||
|
db_source = episode['source']
|
||||||
|
video_path = episode['video_path']
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
if video_path:
|
||||||
|
video_file = Path(video_path)
|
||||||
|
nfo_path = video_file.with_suffix('.nfo')
|
||||||
|
else:
|
||||||
|
# Construct expected path
|
||||||
|
for tv_path in config.tv_paths:
|
||||||
|
series_dirs = [d for d in tv_path.iterdir() if d.is_dir() and imdb_id in str(d)]
|
||||||
|
if series_dirs:
|
||||||
|
season_dir = series_dirs[0] / f"Season {season:02d}"
|
||||||
|
if season_dir.exists():
|
||||||
|
episode_files = list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.nfo"))
|
||||||
|
if episode_files:
|
||||||
|
nfo_path = episode_files[0]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if NFO exists
|
||||||
|
if not nfo_path.exists():
|
||||||
|
verification_results["episodes"]["missing_nfo"] += 1
|
||||||
|
verification_results["episodes"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
||||||
|
"issue": "missing_nfo",
|
||||||
|
"message": "NFO file does not exist",
|
||||||
|
"expected_path": str(nfo_path)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if NFO is empty
|
||||||
|
nfo_content = nfo_path.read_text(encoding='utf-8').strip()
|
||||||
|
if not nfo_content:
|
||||||
|
verification_results["episodes"]["empty_nfo"] += 1
|
||||||
|
verification_results["episodes"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
||||||
|
"issue": "empty_nfo",
|
||||||
|
"message": "NFO file exists but is empty",
|
||||||
|
"nfo_path": str(nfo_path)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract NFOGuard data from episode NFO
|
||||||
|
nfo_data = nfo_manager.extract_nfoguard_dates_from_episode_nfo(nfo_path)
|
||||||
|
|
||||||
|
if not nfo_data:
|
||||||
|
verification_results["episodes"]["date_mismatch"] += 1
|
||||||
|
verification_results["episodes"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
||||||
|
"issue": "no_nfoguard_data",
|
||||||
|
"message": "NFO exists but contains no NFOGuard date information",
|
||||||
|
"db_dateadded": str(db_dateadded),
|
||||||
|
"db_source": db_source,
|
||||||
|
"nfo_path": str(nfo_path)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Everything matches
|
||||||
|
verification_results["episodes"]["verified"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
verification_results["episodes"]["issues"].append({
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"episode": f"S{season:02d}E{episode_num:02d}",
|
||||||
|
"issue": "verification_error",
|
||||||
|
"message": f"Error during verification: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
if media_type in ["both", "movies"]:
|
||||||
|
movies = verification_results["movies"]
|
||||||
|
print(f"📽️ MOVIE VERIFICATION COMPLETE:")
|
||||||
|
print(f" Total Movies: {movies['total']}")
|
||||||
|
print(f" ✅ Verified: {movies['verified']}")
|
||||||
|
print(f" ❌ Missing NFO: {movies['missing_nfo']}")
|
||||||
|
print(f" 📄 Empty NFO: {movies['empty_nfo']}")
|
||||||
|
print(f" 🔄 Date Mismatch: {movies['date_mismatch']}")
|
||||||
|
|
||||||
|
if media_type in ["both", "episodes"]:
|
||||||
|
episodes = verification_results["episodes"]
|
||||||
|
print(f"📺 EPISODE VERIFICATION COMPLETE:")
|
||||||
|
print(f" Total Episodes: {episodes['total']}")
|
||||||
|
print(f" ✅ Verified: {episodes['verified']}")
|
||||||
|
print(f" ❌ Missing NFO: {episodes['missing_nfo']}")
|
||||||
|
print(f" 📄 Empty NFO: {episodes['empty_nfo']}")
|
||||||
|
print(f" 🔄 Date Mismatch: {episodes['date_mismatch']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"NFO verification completed for {media_type}",
|
||||||
|
"results": verification_results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to verify NFO files"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fix_nfo_sync_issues(dependencies: dict, media_type: str = "both"):
|
||||||
|
"""Fix NFO sync issues by regenerating NFO files from database data"""
|
||||||
|
db = dependencies["db"]
|
||||||
|
nfo_manager = dependencies["nfo_manager"]
|
||||||
|
config = dependencies["config"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First run verification to identify issues
|
||||||
|
verification_result = await verify_nfo_sync(dependencies, media_type)
|
||||||
|
if not verification_result["success"]:
|
||||||
|
return verification_result
|
||||||
|
|
||||||
|
results = verification_result["results"]
|
||||||
|
fix_results = {
|
||||||
|
"movies": {"fixed": 0, "failed": 0, "errors": []},
|
||||||
|
"episodes": {"fixed": 0, "failed": 0, "errors": []}
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"🔧 NFO FIX STARTED: Regenerating NFO files for {media_type}")
|
||||||
|
|
||||||
|
# Fix Movies
|
||||||
|
if media_type in ["both", "movies"]:
|
||||||
|
print("📽️ Fixing movie NFO files...")
|
||||||
|
|
||||||
|
for issue in results["movies"]["issues"]:
|
||||||
|
if issue["issue"] in ["empty_nfo", "no_nfoguard_data", "date_mismatch"]:
|
||||||
|
imdb_id = issue["imdb_id"]
|
||||||
|
movie_path = issue["path"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get movie data from database
|
||||||
|
movie_data = db.get_movie_dates(imdb_id)
|
||||||
|
if not movie_data:
|
||||||
|
fix_results["movies"]["errors"].append(f"No database data for {imdb_id}")
|
||||||
|
fix_results["movies"]["failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
dateadded = movie_data.get("dateadded")
|
||||||
|
released = movie_data.get("released")
|
||||||
|
source = movie_data.get("source")
|
||||||
|
|
||||||
|
# Convert datetime to string if needed
|
||||||
|
if hasattr(dateadded, 'isoformat'):
|
||||||
|
dateadded = dateadded.isoformat()
|
||||||
|
if released and hasattr(released, 'isoformat'):
|
||||||
|
released = released.isoformat()
|
||||||
|
|
||||||
|
# Regenerate NFO file
|
||||||
|
from pathlib import Path
|
||||||
|
movie_path_obj = Path(movie_path)
|
||||||
|
|
||||||
|
if config.manage_nfo:
|
||||||
|
nfo_manager.create_movie_nfo(
|
||||||
|
movie_path_obj, imdb_id, dateadded, released, source, config.lock_metadata
|
||||||
|
)
|
||||||
|
print(f"✅ Fixed NFO for {imdb_id}: {movie_path}")
|
||||||
|
fix_results["movies"]["fixed"] += 1
|
||||||
|
else:
|
||||||
|
fix_results["movies"]["errors"].append(f"MANAGE_NFO disabled - cannot fix {imdb_id}")
|
||||||
|
fix_results["movies"]["failed"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
fix_results["movies"]["errors"].append(f"Error fixing {imdb_id}: {str(e)}")
|
||||||
|
fix_results["movies"]["failed"] += 1
|
||||||
|
|
||||||
|
# Fix Episodes
|
||||||
|
if media_type in ["both", "episodes"]:
|
||||||
|
print("📺 Fixing TV episode NFO files...")
|
||||||
|
|
||||||
|
for issue in results["episodes"]["issues"]:
|
||||||
|
if issue["issue"] in ["empty_nfo", "no_nfoguard_data", "date_mismatch"]:
|
||||||
|
imdb_id = issue["imdb_id"]
|
||||||
|
episode_str = issue["episode"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse season/episode from string like "S01E05"
|
||||||
|
import re
|
||||||
|
match = re.match(r'S(\d+)E(\d+)', episode_str)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
season = int(match.group(1))
|
||||||
|
episode_num = int(match.group(2))
|
||||||
|
|
||||||
|
# Get episode data from database
|
||||||
|
episode_data = db.get_episode_date(imdb_id, season, episode_num)
|
||||||
|
if not episode_data:
|
||||||
|
fix_results["episodes"]["errors"].append(f"No database data for {episode_str}")
|
||||||
|
fix_results["episodes"]["failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
air_date = episode_data.get("air_date")
|
||||||
|
dateadded = episode_data.get("dateadded")
|
||||||
|
source = episode_data.get("source")
|
||||||
|
video_path = episode_data.get("video_path")
|
||||||
|
|
||||||
|
# Convert datetime to string if needed
|
||||||
|
if hasattr(air_date, 'isoformat'):
|
||||||
|
air_date = air_date.isoformat()
|
||||||
|
if hasattr(dateadded, 'isoformat'):
|
||||||
|
dateadded = dateadded.isoformat()
|
||||||
|
|
||||||
|
# Find the episode file
|
||||||
|
from pathlib import Path
|
||||||
|
if video_path:
|
||||||
|
episode_file = Path(video_path)
|
||||||
|
else:
|
||||||
|
# Try to find it by scanning
|
||||||
|
episode_file = None
|
||||||
|
for tv_path in config.tv_paths:
|
||||||
|
series_dirs = [d for d in tv_path.iterdir() if d.is_dir() and imdb_id in str(d)]
|
||||||
|
if series_dirs:
|
||||||
|
season_dir = series_dirs[0] / f"Season {season:02d}"
|
||||||
|
if season_dir.exists():
|
||||||
|
video_files = list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.mkv")) + \
|
||||||
|
list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.mp4")) + \
|
||||||
|
list(season_dir.glob(f"*S{season:02d}E{episode_num:02d}*.avi"))
|
||||||
|
if video_files:
|
||||||
|
episode_file = video_files[0]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not episode_file or not episode_file.exists():
|
||||||
|
fix_results["episodes"]["errors"].append(f"Cannot find video file for {episode_str}")
|
||||||
|
fix_results["episodes"]["failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regenerate NFO file
|
||||||
|
if config.manage_nfo:
|
||||||
|
nfo_manager.create_episode_nfo(
|
||||||
|
episode_file, imdb_id, season, episode_num, air_date, dateadded, source, config.lock_metadata
|
||||||
|
)
|
||||||
|
print(f"✅ Fixed NFO for {episode_str}: {episode_file}")
|
||||||
|
fix_results["episodes"]["fixed"] += 1
|
||||||
|
else:
|
||||||
|
fix_results["episodes"]["errors"].append(f"MANAGE_NFO disabled - cannot fix {episode_str}")
|
||||||
|
fix_results["episodes"]["failed"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
fix_results["episodes"]["errors"].append(f"Error fixing {episode_str}: {str(e)}")
|
||||||
|
fix_results["episodes"]["failed"] += 1
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
movies = fix_results["movies"]
|
||||||
|
episodes = fix_results["episodes"]
|
||||||
|
|
||||||
|
print(f"🔧 NFO FIX COMPLETED:")
|
||||||
|
if media_type in ["both", "movies"]:
|
||||||
|
print(f" 📽️ Movies: {movies['fixed']} fixed, {movies['failed']} failed")
|
||||||
|
if media_type in ["both", "episodes"]:
|
||||||
|
print(f" 📺 Episodes: {episodes['fixed']} fixed, {episodes['failed']} failed")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"NFO fix completed for {media_type}",
|
||||||
|
"fix_results": fix_results,
|
||||||
|
"original_verification": results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to fix NFO sync issues"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_movie_release_dates(dependencies: dict):
|
||||||
|
"""Backfill missing release dates for existing movies"""
|
||||||
|
db = dependencies["db"]
|
||||||
|
movie_processor = dependencies["movie_processor"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all movies with missing release dates
|
||||||
|
with db.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Find movies where released is NULL but source suggests we found a release date
|
||||||
|
query = """
|
||||||
|
SELECT imdb_id, path, dateadded, source
|
||||||
|
FROM movies
|
||||||
|
WHERE released IS NULL
|
||||||
|
AND (source LIKE '%tmdb%' OR source LIKE '%omdb%' OR source LIKE '%premiered%')
|
||||||
|
ORDER BY last_updated DESC
|
||||||
|
"""
|
||||||
|
cursor.execute(query)
|
||||||
|
movies_to_backfill = cursor.fetchall()
|
||||||
|
|
||||||
|
if not movies_to_backfill:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "No movies found that need release date backfill",
|
||||||
|
"movies_processed": 0,
|
||||||
|
"movies_updated": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
print(f"🔄 BACKFILL STARTED: Found {len(movies_to_backfill)} movies needing release date backfill")
|
||||||
|
|
||||||
|
for movie in movies_to_backfill:
|
||||||
|
imdb_id = movie['imdb_id']
|
||||||
|
source = movie['source']
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"🔍 Processing {imdb_id} (source: {source})")
|
||||||
|
|
||||||
|
# Try to get release date based on the source
|
||||||
|
release_date = None
|
||||||
|
|
||||||
|
if 'tmdb' in source or 'omdb' in source:
|
||||||
|
# Re-fetch digital release date
|
||||||
|
digital_date, _ = movie_processor._get_digital_release_date(imdb_id)
|
||||||
|
if digital_date:
|
||||||
|
release_date = digital_date
|
||||||
|
print(f"✅ Found release date for {imdb_id}: {release_date}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Could not re-fetch release date for {imdb_id}")
|
||||||
|
|
||||||
|
elif 'premiered' in source:
|
||||||
|
# Use the dateadded as the release date for premiered sources
|
||||||
|
release_date = movie['dateadded']
|
||||||
|
if hasattr(release_date, 'isoformat'):
|
||||||
|
release_date = release_date.isoformat()
|
||||||
|
print(f"✅ Using premiered date for {imdb_id}: {release_date}")
|
||||||
|
|
||||||
|
# Update the database if we found a release date
|
||||||
|
if release_date:
|
||||||
|
db.upsert_movie_dates(imdb_id, release_date, movie['dateadded'], source, True)
|
||||||
|
updated_count += 1
|
||||||
|
print(f"📝 Updated release date for {imdb_id}")
|
||||||
|
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
# Small delay to avoid overwhelming APIs
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error processing {imdb_id}: {e}")
|
||||||
|
processed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"✅ BACKFILL COMPLETED: Processed {processed_count} movies, updated {updated_count} with release dates")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Backfill completed: processed {processed_count} movies, updated {updated_count} with release dates",
|
||||||
|
"movies_processed": processed_count,
|
||||||
|
"movies_updated": updated_count,
|
||||||
|
"movies_found": len(movies_to_backfill)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to backfill movie release dates"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Route Registration
|
# Route Registration
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
@@ -1307,6 +1810,18 @@ def register_routes(app, dependencies: dict):
|
|||||||
async def _cleanup_orphaned_series():
|
async def _cleanup_orphaned_series():
|
||||||
return await cleanup_orphaned_series(dependencies)
|
return await cleanup_orphaned_series(dependencies)
|
||||||
|
|
||||||
|
@app.post("/database/backfill/movie-release-dates")
|
||||||
|
async def _backfill_movie_release_dates():
|
||||||
|
return await backfill_movie_release_dates(dependencies)
|
||||||
|
|
||||||
|
@app.post("/database/verify/nfo-sync")
|
||||||
|
async def _verify_nfo_sync(media_type: str = "both"):
|
||||||
|
return await verify_nfo_sync(dependencies, media_type)
|
||||||
|
|
||||||
|
@app.post("/database/fix/nfo-sync")
|
||||||
|
async def _fix_nfo_sync_issues(media_type: str = "both"):
|
||||||
|
return await fix_nfo_sync_issues(dependencies, media_type)
|
||||||
|
|
||||||
@app.post("/manual/scan")
|
@app.post("/manual/scan")
|
||||||
async def _manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = None, scan_type: str = "both", scan_mode: str = "smart"):
|
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)
|
return await manual_scan(background_tasks, path, scan_type, scan_mode, dependencies)
|
||||||
@@ -1396,6 +1911,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)
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from core.database import NFOGuardDatabase
|
from core.database import NFOGuardDatabase
|
||||||
|
from config.settings import config
|
||||||
|
|
||||||
def debug_movie(imdb_id: str):
|
def debug_movie(imdb_id: str):
|
||||||
"""Debug a specific movie's data"""
|
"""Debug a specific movie's data"""
|
||||||
@@ -17,7 +18,7 @@ def debug_movie(imdb_id: str):
|
|||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
db = NFOGuardDatabase()
|
db = NFOGuardDatabase(config=config)
|
||||||
|
|
||||||
# Get movie data
|
# Get movie data
|
||||||
movie = db.get_movie_dates(imdb_id)
|
movie = db.get_movie_dates(imdb_id)
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
#!/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
|
||||||
|
from config.settings import config
|
||||||
|
|
||||||
|
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(config=config)
|
||||||
|
|
||||||
|
# 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(config=config)
|
||||||
|
|
||||||
|
# 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(config=config)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -530,7 +530,8 @@ class MovieProcessor:
|
|||||||
# Compare dates - prefer release date if it's reasonable
|
# Compare dates - prefer release date if it's reasonable
|
||||||
if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id):
|
if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id):
|
||||||
_log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date")
|
_log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date")
|
||||||
return digital_date, digital_source, released
|
# When using digital release date, store it as both dateadded and released
|
||||||
|
return digital_date, digital_source, digital_date
|
||||||
else:
|
else:
|
||||||
# Convert file date to local timezone for NFO files
|
# Convert file date to local timezone for NFO files
|
||||||
local_file_date = convert_utc_to_local(import_date)
|
local_file_date = convert_utc_to_local(import_date)
|
||||||
@@ -545,7 +546,8 @@ class MovieProcessor:
|
|||||||
return local_import_date, import_source, released
|
return local_import_date, import_source, released
|
||||||
elif digital_date:
|
elif digital_date:
|
||||||
_log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}")
|
_log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}")
|
||||||
return digital_date, digital_source, released
|
# When using digital release date, store it as both dateadded and released
|
||||||
|
return digital_date, digital_source, digital_date
|
||||||
else:
|
else:
|
||||||
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
|
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
|
||||||
|
|
||||||
@@ -553,13 +555,15 @@ class MovieProcessor:
|
|||||||
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
||||||
if radarr_premiered:
|
if radarr_premiered:
|
||||||
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
|
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
|
||||||
return radarr_premiered, "radarr:nfo.premiered", released
|
# When using Radarr NFO premiered date, store it as both dateadded and released
|
||||||
|
return radarr_premiered, "radarr:nfo.premiered", radarr_premiered
|
||||||
|
|
||||||
else: # digital_then_import
|
else: # digital_then_import
|
||||||
# Try digital release first
|
# Try digital release first
|
||||||
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
||||||
if digital_date:
|
if digital_date:
|
||||||
return digital_date, digital_source, released
|
# When using digital release date, store it as both dateadded and released
|
||||||
|
return digital_date, digital_source, digital_date
|
||||||
|
|
||||||
# Fall back to import history
|
# Fall back to import history
|
||||||
if radarr_movie:
|
if radarr_movie:
|
||||||
|
|||||||
@@ -263,9 +263,11 @@ class TVSeriesProcessor:
|
|||||||
if episode_id:
|
if episode_id:
|
||||||
import_date = self.sonarr.get_episode_import_history(episode_id)
|
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||||
if import_date:
|
if import_date:
|
||||||
dateadded = convert_utc_to_local(import_date)
|
# Sonarr import dates are already in local timezone despite 'Z' suffix
|
||||||
|
# Remove 'Z' and use as-is to avoid double timezone conversion
|
||||||
|
dateadded = import_date.replace('Z', '') if 'Z' in import_date else import_date
|
||||||
source = "sonarr:history.import"
|
source = "sonarr:history.import"
|
||||||
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded} (no timezone conversion)")
|
||||||
return aired, dateadded, source
|
return aired, dateadded, source
|
||||||
|
|
||||||
# Fallback to airdate if no import history
|
# Fallback to airdate if no import history
|
||||||
|
|||||||
@@ -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