Merge branch 'dev'
Local Docker Build (Main) / build (push) Successful in 4s
Local Docker Build (Main) / deploy (push) Successful in 0s

This commit is contained in:
2025-10-22 08:37:51 -04:00
15 changed files with 1151 additions and 25 deletions
+9
View File
@@ -135,6 +135,15 @@ PATH_DEBUG=false
# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
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
# ===========================================
+8
View File
@@ -39,3 +39,11 @@ JELLYSEERR_API_KEY=your_jellyseerr_api_key
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
# Get free API key at: https://thetvdb.com/api-information
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
+55 -15
View File
@@ -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.
>
> **💬 Community Feedback**: Join our Discord if youd 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)**
>
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
> **[Join Discord: https://discord.gg/ZykJRGt72b](https://discord.gg/ZykJRGt72b)**
---
@@ -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
- **PostgreSQL Database** - Production-ready database with optimized queries
- **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**
- **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
```bash
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/docker-compose.example.yml
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/docker-compose.example.yml
```
### 2. Configure Environment
@@ -149,10 +143,10 @@ image: sbcrumb/nfoguard:dev
### Specific Version
```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
@@ -184,8 +178,44 @@ curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
# Bulk update all movies from Radarr database
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
#### **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/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**
| Endpoint | Method | Purpose |
|----------|--------|---------|
@@ -499,7 +539,7 @@ NFOGuard will ignore:
## 🆘 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
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
+1 -1
View File
@@ -1 +1 @@
2.6.7
2.6.12
+182
View File
@@ -0,0 +1,182 @@
"""
Simple authentication middleware for NFOGuard web interface
Provides basic HTTP auth and session management for web interface protection
"""
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, status, Request, Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.base import BaseHTTPMiddleware
class AuthSession:
"""Simple session management for web interface"""
def __init__(self, timeout_seconds: int = 3600):
self.sessions: Dict[str, Dict[str, Any]] = {}
self.timeout_seconds = timeout_seconds
def create_session(self, username: str) -> str:
"""Create a new session and return session token"""
session_token = secrets.token_urlsafe(32)
self.sessions[session_token] = {
"username": username,
"created_at": datetime.utcnow(),
"last_activity": datetime.utcnow()
}
return session_token
def validate_session(self, session_token: str) -> bool:
"""Validate session token and update last activity"""
if not session_token or session_token not in self.sessions:
return False
session = self.sessions[session_token]
now = datetime.utcnow()
# Check if session expired
if (now - session["last_activity"]).seconds > self.timeout_seconds:
del self.sessions[session_token]
return False
# Update last activity
session["last_activity"] = now
return True
def get_session_user(self, session_token: str) -> Optional[str]:
"""Get username from valid session"""
if self.validate_session(session_token):
return self.sessions[session_token]["username"]
return None
def delete_session(self, session_token: str) -> None:
"""Delete a session (logout)"""
if session_token in self.sessions:
del self.sessions[session_token]
def cleanup_expired_sessions(self) -> None:
"""Remove expired sessions"""
now = datetime.utcnow()
expired_tokens = []
for token, session in self.sessions.items():
if (now - session["last_activity"]).seconds > self.timeout_seconds:
expired_tokens.append(token)
for token in expired_tokens:
del self.sessions[token]
class SimpleAuthMiddleware(BaseHTTPMiddleware):
"""Simple authentication middleware for web interface routes"""
def __init__(self, app, config):
super().__init__(app)
self.config = config
self.session_manager = AuthSession(config.web_auth_session_timeout)
self.security = HTTPBasic()
# Routes that require authentication (web interface)
self.protected_routes = [
"/", # Main web interface
"/static/", # Static files (CSS, JS)
"/api/movies", # Web API endpoints
"/api/series",
"/api/episodes",
"/api/dashboard"
]
# Routes that are always public (webhooks, health checks, API endpoints)
self.public_routes = [
"/webhook/",
"/health",
"/ping",
"/api/v1/health",
"/api/v1/metrics",
"/database/", # Database management endpoints (API access)
"/manual/", # Manual scan endpoints (API access)
"/debug/", # Debug endpoints (API access)
"/test/", # Test endpoints (API access)
"/bulk/" # Bulk operation endpoints (API access)
]
async def dispatch(self, request: Request, call_next):
"""Process request through authentication middleware"""
# Skip authentication if disabled
if not self.config.web_auth_enabled:
return await call_next(request)
# Check if route requires authentication
path = request.url.path
needs_auth = any(path.startswith(route) for route in self.protected_routes)
is_public = any(path.startswith(route) for route in self.public_routes)
if is_public or not needs_auth:
return await call_next(request)
# Check for existing session
session_token = request.cookies.get("nfoguard_session")
if session_token and self.session_manager.validate_session(session_token):
# Valid session, proceed
return await call_next(request)
# Check for HTTP Basic Auth
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Basic "):
credentials = self._parse_basic_auth(auth_header)
if credentials and self._validate_credentials(credentials.username, credentials.password):
# Create session for successful login
session_token = self.session_manager.create_session(credentials.username)
response = await call_next(request)
response.set_cookie(
key="nfoguard_session",
value=session_token,
max_age=self.config.web_auth_session_timeout,
httponly=True,
secure=False # Set to True if using HTTPS
)
return response
# Authentication required
return self._auth_required_response()
def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
"""Parse HTTP Basic Auth header"""
try:
import base64
encoded_credentials = auth_header.split(" ")[1]
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
username, password = decoded_credentials.split(":", 1)
return HTTPBasicCredentials(username=username, password=password)
except Exception:
return None
def _validate_credentials(self, username: str, password: str) -> bool:
"""Validate username and password"""
return (username == self.config.web_auth_username and
password == self.config.web_auth_password)
def _auth_required_response(self) -> Response:
"""Return 401 response with WWW-Authenticate header"""
return Response(
content="Authentication required",
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
)
def create_auth_dependencies(config) -> Dict[str, Any]:
"""Create authentication-related dependencies for dependency injection"""
session_manager = AuthSession(config.web_auth_session_timeout)
return {
"session_manager": session_manager,
"auth_enabled": config.web_auth_enabled,
"auth_config": {
"username": config.web_auth_username,
"timeout": config.web_auth_session_timeout
}
}
+548 -1
View File
@@ -7,7 +7,7 @@ import requests
import asyncio
from pathlib import Path
from datetime import datetime, timezone
from fastapi import HTTPException, BackgroundTasks, Request
from fastapi import HTTPException, BackgroundTasks, Request, Response
from typing import Optional
# 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
# ---------------------------
@@ -1307,6 +1810,18 @@ def register_routes(app, dependencies: dict):
async def _cleanup_orphaned_series():
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")
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)
@@ -1396,6 +1911,38 @@ def register_routes(app, dependencies: dict):
"""Update episode dateadded"""
return await update_episode_date(dependencies, imdb_id, season, episode, request.dateadded, request.source)
@app.post("/api/auth/logout")
async def _logout(request: Request, response: Response):
"""Logout endpoint - clears session"""
session_manager = dependencies.get("session_manager")
if session_manager:
session_token = request.cookies.get("nfoguard_session")
if session_token:
session_manager.delete_session(session_token)
response.delete_cookie("nfoguard_session")
return {"status": "logged_out", "message": "Session cleared"}
@app.get("/api/auth/status")
async def _auth_status(request: Request):
"""Check authentication status"""
auth_enabled = dependencies.get("auth_enabled", False)
if not auth_enabled:
return {"authenticated": True, "auth_enabled": False, "message": "Authentication disabled"}
session_manager = dependencies.get("session_manager")
if not session_manager:
return {"authenticated": False, "auth_enabled": True, "message": "Session manager not available"}
session_token = request.cookies.get("nfoguard_session")
if session_token:
username = session_manager.get_session_user(session_token)
if username:
return {"authenticated": True, "auth_enabled": True, "username": username}
return {"authenticated": False, "auth_enabled": True, "message": "Not authenticated"}
@app.post("/api/bulk/update-source")
async def _bulk_update_source(request: BulkUpdateRequest):
"""Bulk update source for movies or episodes"""
+10
View File
@@ -80,6 +80,9 @@ class NFOGuardConfig:
# TV processing
self._load_tv_settings()
# Web interface authentication
self._load_auth_settings()
def _load_paths(self) -> None:
"""Load and validate path configuration"""
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_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:
"""Get integer environment variable with validation"""
value_str = os.environ.get(name)
+2 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from core.database import NFOGuardDatabase
from config.settings import config
def debug_movie(imdb_id: str):
"""Debug a specific movie's data"""
@@ -17,7 +18,7 @@ def debug_movie(imdb_id: str):
print("=" * 50)
# Initialize database
db = NFOGuardDatabase()
db = NFOGuardDatabase(config=config)
# Get movie data
movie = db.get_movie_dates(imdb_id)
+202
View File
@@ -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)
+14
View File
@@ -15,6 +15,9 @@ from fastapi import FastAPI
# Import configuration first
from config.settings import config
# Import authentication
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
from utils.logging import _log
# Import core components
@@ -172,6 +175,17 @@ def main():
# Initialize components
dependencies = initialize_components()
# Add authentication dependencies
auth_deps = create_auth_dependencies(config)
dependencies.update(auth_deps)
# Add authentication middleware if enabled
if config.web_auth_enabled:
app.add_middleware(SimpleAuthMiddleware, config=config)
_log("INFO", f"Web authentication enabled for user: {config.web_auth_username}")
else:
_log("INFO", "Web authentication disabled - web interface is public")
# Store dependencies globally for signal handler access
signal_handler.dependencies = dependencies
+8 -4
View File
@@ -530,7 +530,8 @@ class MovieProcessor:
# Compare dates - prefer release date if it's reasonable
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")
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:
# Convert file date to local timezone for NFO files
local_file_date = convert_utc_to_local(import_date)
@@ -545,7 +546,8 @@ class MovieProcessor:
return local_import_date, import_source, released
elif digital_date:
_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:
_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)
if 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
# Try digital release first
digital_date, digital_source = self._get_digital_release_date(imdb_id)
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
if radarr_movie:
+4 -2
View File
@@ -263,9 +263,11 @@ class TVSeriesProcessor:
if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id)
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"
_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
# Fallback to airdate if no import history
+40
View File
@@ -39,6 +39,7 @@ body {
color: white;
padding: 1rem 0;
box-shadow: var(--shadow-lg);
position: relative;
}
.header-content {
@@ -63,6 +64,45 @@ body {
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 {
max-width: 1200px;
margin: 1rem auto 0;
+8
View File
@@ -15,6 +15,14 @@
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
<p>Database Management & Reporting</p>
</div>
<div class="auth-status" id="auth-status" style="display: none;">
<span class="auth-user">
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
</span>
<button class="auth-logout" id="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</div>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard
+59
View File
@@ -10,6 +10,7 @@ let dashboardData = null;
document.addEventListener('DOMContentLoaded', function() {
initializeTabs();
initializeEventListeners();
checkAuthStatus(); // Check authentication status on page load
loadDashboard();
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');
}
}