diff --git a/.env.example b/.env.example index 7916a30..49a2925 100644 --- a/.env.example +++ b/.env.example @@ -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 # =========================================== diff --git a/.env.secrets.example b/.env.secrets.example index 6653c39..e051d1c 100644 --- a/.env.secrets.example +++ b/.env.secrets.example @@ -38,4 +38,12 @@ JELLYSEERR_API_KEY=your_jellyseerr_api_key # TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility) # 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 \ No newline at end of file +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 \ No newline at end of file diff --git a/README.md b/README.md index 8f11aad..3e01a41 100644 --- a/README.md +++ b/README.md @@ -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 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)** -> -> *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) diff --git a/VERSION b/VERSION index e261122..c959dfb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.7 +2.6.12 diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..9688f0e --- /dev/null +++ b/api/auth.py @@ -0,0 +1,182 @@ +""" +Simple authentication middleware for NFOGuard web interface +Provides basic HTTP auth and session management for web interface protection +""" +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from fastapi import HTTPException, status, Request, Response +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.middleware.base import BaseHTTPMiddleware + + +class AuthSession: + """Simple session management for web interface""" + + def __init__(self, timeout_seconds: int = 3600): + self.sessions: Dict[str, Dict[str, Any]] = {} + self.timeout_seconds = timeout_seconds + + def create_session(self, username: str) -> str: + """Create a new session and return session token""" + session_token = secrets.token_urlsafe(32) + self.sessions[session_token] = { + "username": username, + "created_at": datetime.utcnow(), + "last_activity": datetime.utcnow() + } + return session_token + + def validate_session(self, session_token: str) -> bool: + """Validate session token and update last activity""" + if not session_token or session_token not in self.sessions: + return False + + session = self.sessions[session_token] + now = datetime.utcnow() + + # Check if session expired + if (now - session["last_activity"]).seconds > self.timeout_seconds: + del self.sessions[session_token] + return False + + # Update last activity + session["last_activity"] = now + return True + + def get_session_user(self, session_token: str) -> Optional[str]: + """Get username from valid session""" + if self.validate_session(session_token): + return self.sessions[session_token]["username"] + return None + + def delete_session(self, session_token: str) -> None: + """Delete a session (logout)""" + if session_token in self.sessions: + del self.sessions[session_token] + + def cleanup_expired_sessions(self) -> None: + """Remove expired sessions""" + now = datetime.utcnow() + expired_tokens = [] + + for token, session in self.sessions.items(): + if (now - session["last_activity"]).seconds > self.timeout_seconds: + expired_tokens.append(token) + + for token in expired_tokens: + del self.sessions[token] + + +class SimpleAuthMiddleware(BaseHTTPMiddleware): + """Simple authentication middleware for web interface routes""" + + def __init__(self, app, config): + super().__init__(app) + self.config = config + self.session_manager = AuthSession(config.web_auth_session_timeout) + self.security = HTTPBasic() + + # Routes that require authentication (web interface) + self.protected_routes = [ + "/", # Main web interface + "/static/", # Static files (CSS, JS) + "/api/movies", # Web API endpoints + "/api/series", + "/api/episodes", + "/api/dashboard" + ] + + # Routes that are always public (webhooks, health checks, API endpoints) + self.public_routes = [ + "/webhook/", + "/health", + "/ping", + "/api/v1/health", + "/api/v1/metrics", + "/database/", # Database management endpoints (API access) + "/manual/", # Manual scan endpoints (API access) + "/debug/", # Debug endpoints (API access) + "/test/", # Test endpoints (API access) + "/bulk/" # Bulk operation endpoints (API access) + ] + + async def dispatch(self, request: Request, call_next): + """Process request through authentication middleware""" + + # Skip authentication if disabled + if not self.config.web_auth_enabled: + return await call_next(request) + + # Check if route requires authentication + path = request.url.path + needs_auth = any(path.startswith(route) for route in self.protected_routes) + is_public = any(path.startswith(route) for route in self.public_routes) + + if is_public or not needs_auth: + return await call_next(request) + + # Check for existing session + session_token = request.cookies.get("nfoguard_session") + if session_token and self.session_manager.validate_session(session_token): + # Valid session, proceed + return await call_next(request) + + # Check for HTTP Basic Auth + auth_header = request.headers.get("authorization") + if auth_header and auth_header.startswith("Basic "): + credentials = self._parse_basic_auth(auth_header) + if credentials and self._validate_credentials(credentials.username, credentials.password): + # Create session for successful login + session_token = self.session_manager.create_session(credentials.username) + response = await call_next(request) + response.set_cookie( + key="nfoguard_session", + value=session_token, + max_age=self.config.web_auth_session_timeout, + httponly=True, + secure=False # Set to True if using HTTPS + ) + return response + + # Authentication required + return self._auth_required_response() + + def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]: + """Parse HTTP Basic Auth header""" + try: + import base64 + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8') + username, password = decoded_credentials.split(":", 1) + return HTTPBasicCredentials(username=username, password=password) + except Exception: + return None + + def _validate_credentials(self, username: str, password: str) -> bool: + """Validate username and password""" + return (username == self.config.web_auth_username and + password == self.config.web_auth_password) + + def _auth_required_response(self) -> Response: + """Return 401 response with WWW-Authenticate header""" + return Response( + content="Authentication required", + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""} + ) + + +def create_auth_dependencies(config) -> Dict[str, Any]: + """Create authentication-related dependencies for dependency injection""" + session_manager = AuthSession(config.web_auth_session_timeout) + + return { + "session_manager": session_manager, + "auth_enabled": config.web_auth_enabled, + "auth_config": { + "username": config.web_auth_username, + "timeout": config.web_auth_session_timeout + } + } \ No newline at end of file diff --git a/api/routes.py b/api/routes.py index 210a1b9..77e85d9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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""" diff --git a/config/settings.py b/config/settings.py index fff5d28..a49dad0 100644 --- a/config/settings.py +++ b/config/settings.py @@ -79,6 +79,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""" @@ -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) diff --git a/debug_movie.py b/debug_movie.py index 9b1ab32..c21a7f8 100644 --- a/debug_movie.py +++ b/debug_movie.py @@ -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) diff --git a/debug_tv.py b/debug_tv.py new file mode 100644 index 0000000..7d0044d --- /dev/null +++ b/debug_tv.py @@ -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 # Debug entire series") + print(" python debug_tv.py # Debug specific season") + print(" python debug_tv.py # 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) \ No newline at end of file diff --git a/main.py b/main.py index e4e3003..9b95bfb 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/processors/movie_processor.py b/processors/movie_processor.py index 692acac..4b18192 100644 --- a/processors/movie_processor.py +++ b/processors/movie_processor.py @@ -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: diff --git a/processors/tv_series_processor.py b/processors/tv_series_processor.py index a5d3c81..c30189a 100644 --- a/processors/tv_series_processor.py +++ b/processors/tv_series_processor.py @@ -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 diff --git a/static/css/styles.css b/static/css/styles.css index 9dd6eba..c41f5fb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -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; diff --git a/static/index.html b/static/index.html index 124bf05..5aee3c9 100644 --- a/static/index.html +++ b/static/index.html @@ -15,6 +15,14 @@

NFOGuard

Database Management & Reporting

+