diff --git a/README.md b/README.md index 633f4c4..3e01a41 100644 --- a/README.md +++ b/README.md @@ -178,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** @@ -215,6 +251,8 @@ curl -X POST "http://localhost:8080/bulk/update" | `/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 | diff --git a/api/routes.py b/api/routes.py index f7b710f..77e85d9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1233,6 +1233,414 @@ 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"] @@ -1406,6 +1814,14 @@ def register_routes(app, dependencies: dict): 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)