From 9072c867797698164a3566a43bcdea092122d7ed Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:48:26 -0400 Subject: [PATCH 1/6] imdb: missing imdge improvments --- api/routes.py | 104 ++++++++++++++++++++++++-- api/web_routes.py | 57 +++++++++++++++ core/database.py | 90 +++++++++++++++++++++++ nfoguard-web/static/css/styles.css | 113 +++++++++++++++++++++++++++++ nfoguard-web/static/index.html | 3 + nfoguard-web/static/js/app.js | 110 ++++++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 5 deletions(-) diff --git a/api/routes.py b/api/routes.py index e9e6209..cd33c43 100644 --- a/api/routes.py +++ b/api/routes.py @@ -844,9 +844,25 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N for item in scan_path.iterdir(): if (item.is_dir() and not item.name.lower().startswith('season') and - not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and - nfo_manager.parse_imdb_from_path(item)): - tv_series_list.append(item) + not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE)): + + # Check for IMDb ID + imdb_id = nfo_manager.parse_imdb_from_path(item) + if imdb_id: + tv_series_list.append(item) + else: + # Log missing IMDb ID for TV series + try: + db.add_missing_imdb( + file_path=str(item), + media_type="tv", + folder_name=item.name, + filename=None, + notes=f"TV series directory without IMDb ID detected during scan" + ) + print(f"⚠️ Missing IMDb ID: TV series {item.name} - logged for manual review") + except Exception as e: + print(f"❌ Failed to log missing IMDb for TV series {item.name}: {e}") tv_series_count = len(tv_series_list) update_scan_status("tv", tv_series_total=tv_series_count) @@ -894,8 +910,24 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N # Count total movies first for progress tracking movie_list = [] for item in scan_path.iterdir(): - if item.is_dir() and nfo_manager.find_movie_imdb_id(item): - movie_list.append(item) + if item.is_dir(): + # Check for IMDb ID + imdb_id = nfo_manager.find_movie_imdb_id(item) + if imdb_id: + movie_list.append(item) + else: + # Log missing IMDb ID for movie + try: + db.add_missing_imdb( + file_path=str(item), + media_type="movie", + folder_name=item.name, + filename=None, + notes=f"Movie directory without IMDb ID detected during scan" + ) + print(f"⚠️ Missing IMDb ID: Movie {item.name} - logged for manual review") + except Exception as e: + print(f"❌ Failed to log missing IMDb for movie {item.name}: {e}") movie_total_count = len(movie_list) update_scan_status(movies_total=movie_total_count) @@ -2967,6 +2999,68 @@ def register_routes(app, dependencies: dict): async def _nfo_repair_fix(): return await nfo_repair_fix() + @app.get("/admin/missing-imdb") + async def get_missing_imdb(): + """Get items missing IMDb IDs for manual review""" + config = dependencies.get("config") + db = dependencies.get("db") + + if not config or not db: + raise HTTPException(status_code=500, detail="Dependencies not available") + + print("📋 Retrieving missing IMDb items from core container") + + try: + # Get missing items by type + missing_tv = db.get_missing_imdb_items(media_type="tv", resolved=False) + missing_movies = db.get_missing_imdb_items(media_type="movie", resolved=False) + + # Format for web interface + missing_items = [] + + for item in missing_tv: + missing_items.append({ + "id": item["id"], + "type": "TV Series", + "folder_name": item["folder_name"], + "file_path": item["file_path"], + "discovered_at": item["discovered_at"].isoformat() if item["discovered_at"] else None, + "last_checked": item["last_checked"].isoformat() if item["last_checked"] else None, + "check_count": item["check_count"], + "notes": item["notes"] + }) + + for item in missing_movies: + missing_items.append({ + "id": item["id"], + "type": "Movie", + "folder_name": item["folder_name"], + "file_path": item["file_path"], + "discovered_at": item["discovered_at"].isoformat() if item["discovered_at"] else None, + "last_checked": item["last_checked"].isoformat() if item["last_checked"] else None, + "check_count": item["check_count"], + "notes": item["notes"] + }) + + print(f"✅ Found {len(missing_tv)} TV series and {len(missing_movies)} movies missing IMDb IDs") + + return { + "status": "success", + "missing_items": missing_items, + "summary": { + "total_missing": len(missing_items), + "tv_series": len(missing_tv), + "movies": len(missing_movies) + } + } + + except Exception as e: + print(f"❌ Error retrieving missing IMDb items: {e}") + return { + "status": "error", + "message": f"Failed to retrieve missing IMDb items: {str(e)}" + } + # --------------------------- # Core API - No Web Interface # --------------------------- diff --git a/api/web_routes.py b/api/web_routes.py index fc0a233..e311934 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1676,6 +1676,58 @@ async def fix_nfo_missing_dateadded(dependencies: dict): } +async def get_missing_imdb_items(dependencies: dict): + """Get items missing IMDb IDs for manual review via core container""" + config = dependencies["config"] + + try: + print("📋 Calling core container for missing IMDb items...") + + # Call the core container's missing IMDb endpoint + import aiohttp + import asyncio + + core_url = f"http://nfoguard:{config.core_api_port}/admin/missing-imdb" + print(f"🔍 DEBUG: Calling core container at: {core_url}") + + timeout = aiohttp.ClientTimeout(total=60.0) # 1 minute timeout + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(core_url) as response: + if response.status == 200: + result = await response.json() + print(f"✅ Core container retrieved missing IMDb items: {result['summary']['total_missing']} items") + + return { + "success": True, + "total_missing": result['summary']['total_missing'], + "tv_series_missing": result['summary']['tv_series'], + "movies_missing": result['summary']['movies'], + "items": result['missing_items'], + "debug_info": { + "scan_method": "core_container_database", + "core_container_response": "success" + } + } + else: + error_text = await response.text() + print(f"❌ Core container missing IMDb retrieval failed: {response.status} - {error_text}") + return { + "success": False, + "error": f"Core container retrieval failed: {response.status}", + "message": f"Failed to retrieve missing IMDb items via core container: {response.status}" + } + + except Exception as e: + print(f"❌ Error calling core container for missing IMDb items: {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": f"Core container communication failed: {str(e)}", + "message": f"Failed to retrieve missing IMDb items: {str(e)}" + } + + async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_all: bool = False): """Update NFO files with missing dateadded elements from database""" db = dependencies["db"] @@ -1794,6 +1846,11 @@ def register_database_admin_routes(app, dependencies): """Get episodes missing dateadded in NFO files""" return await get_episodes_missing_nfo_dateadded(dependencies) + @app.get("/api/admin/missing-imdb") + async def api_missing_imdb_items(): + """Get items missing IMDb IDs for manual review""" + return await get_missing_imdb_items(dependencies) + @app.post("/api/admin/nfo/bulk-update") async def api_bulk_update_nfo_files(imdb_ids: list = None, fix_all: bool = False): """Bulk update NFO files with missing dateadded via core container""" diff --git a/core/database.py b/core/database.py index 088f114..de17faf 100644 --- a/core/database.py +++ b/core/database.py @@ -129,11 +129,32 @@ class NFOGuardDatabase: ) """) + # Missing IMDb tracking table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS missing_imdb ( + id SERIAL PRIMARY KEY, + file_path TEXT NOT NULL UNIQUE, + media_type VARCHAR(20) NOT NULL, + folder_name TEXT, + filename TEXT, + discovered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_checked TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + check_count INTEGER DEFAULT 1, + notes TEXT, + resolved BOOLEAN DEFAULT FALSE, + resolved_at TIMESTAMP, + resolved_imdb_id VARCHAR(20) + ) + """) + # Create indexes for PostgreSQL cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_type ON missing_imdb(media_type)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_resolved ON missing_imdb(resolved)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_path ON missing_imdb(file_path)") def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None): """Insert or update series record""" with self.get_connection() as conn: @@ -583,6 +604,75 @@ class NFOGuardDatabase: return deleted_series + def add_missing_imdb(self, file_path: str, media_type: str, folder_name: str = None, filename: str = None, notes: str = None): + """Add or update a missing IMDb entry""" + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO missing_imdb (file_path, media_type, folder_name, filename, notes) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (file_path) DO UPDATE SET + last_checked = CURRENT_TIMESTAMP, + check_count = missing_imdb.check_count + 1, + media_type = EXCLUDED.media_type, + folder_name = EXCLUDED.folder_name, + filename = EXCLUDED.filename, + notes = EXCLUDED.notes + """, (file_path, media_type, folder_name, filename, notes)) + + conn.commit() + + def get_missing_imdb_items(self, media_type: str = None, resolved: bool = False) -> List[Dict]: + """Get missing IMDb items, optionally filtered by type and resolution status""" + with self.get_connection() as conn: + cursor = conn.cursor() + + query = """ + SELECT id, file_path, media_type, folder_name, filename, + discovered_at, last_checked, check_count, notes, + resolved, resolved_at, resolved_imdb_id + FROM missing_imdb + WHERE resolved = %s + """ + params = [resolved] + + if media_type: + query += " AND media_type = %s" + params.append(media_type) + + query += " ORDER BY last_checked DESC" + + cursor.execute(query, params) + return cursor.fetchall() + + def resolve_missing_imdb(self, file_path: str, imdb_id: str): + """Mark a missing IMDb item as resolved""" + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + UPDATE missing_imdb + SET resolved = TRUE, + resolved_at = CURRENT_TIMESTAMP, + resolved_imdb_id = %s + WHERE file_path = %s + """, (imdb_id, file_path)) + + conn.commit() + return cursor.rowcount > 0 + + def delete_missing_imdb(self, file_path: str) -> bool: + """Delete a missing IMDb entry""" + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute("DELETE FROM missing_imdb WHERE file_path = %s", (file_path,)) + deleted_count = cursor.rowcount + conn.commit() + + return deleted_count > 0 + def close(self): """Close all database connections""" if hasattr(self._local, 'connection'): diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css index 42e54f1..5c46bb4 100644 --- a/nfoguard-web/static/css/styles.css +++ b/nfoguard-web/static/css/styles.css @@ -1150,6 +1150,105 @@ textarea { border-color: #117a8b; } +/* Missing IMDb Items Styles */ +.missing-imdb-table-container { + overflow-x: auto; + max-height: 600px; + border: 1px solid var(--border-color); + border-radius: 8px; + margin: 1rem 0; +} + +.missing-imdb-table { + width: 100%; + border-collapse: collapse; + min-width: 800px; +} + +.missing-imdb-table thead { + background-color: var(--bg-secondary); + position: sticky; + top: 0; + z-index: 1; +} + +.missing-imdb-table th, +.missing-imdb-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); + vertical-align: top; +} + +.missing-imdb-table th { + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.missing-imdb-table td { + color: var(--text-secondary); +} + +.missing-imdb-table .file-path { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.85rem; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-type-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.media-type-badge.tv-series { + background-color: #e3f2fd; + color: #1976d2; +} + +.media-type-badge.movie { + background-color: #fff3e0; + color: #f57c00; +} + +.missing-imdb-actions { + background-color: var(--bg-secondary); + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; +} + +.missing-imdb-actions ul { + margin: 0.5rem 0 0 1rem; + padding: 0; +} + +.missing-imdb-actions li { + margin-bottom: 0.5rem; +} + +.missing-imdb-actions code { + background-color: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.85rem; + border: 1px solid var(--border-color); +} + +.btn-small { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + min-width: auto; +} + /* Responsive adjustments for admin tools */ @media (max-width: 768px) { .table-container { @@ -1168,4 +1267,18 @@ textarea { .admin-tools { gap: 0.75rem; } + + .missing-imdb-table { + min-width: 600px; + } + + .missing-imdb-table th, + .missing-imdb-table td { + padding: 0.5rem; + font-size: 0.85rem; + } + + .missing-imdb-table .file-path { + max-width: 200px; + } } \ No newline at end of file diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index fbbbcd6..1f5a72a 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -455,6 +455,9 @@ + diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 7daf249..6eb5b3d 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -2031,6 +2031,90 @@ async function showEpisodesMissingNFODateadded() { } } +async function showMissingImdbItems() { + const resultsDiv = document.getElementById('admin-results'); + const resultsContent = document.getElementById('admin-results-content'); + + try { + resultsContent.innerHTML = '
Checking for items missing IMDb IDs...
'; + resultsDiv.style.display = 'block'; + + const response = await apiCall('/api/admin/missing-imdb'); + + if (response.success) { + let html = ` +

Items Missing IMDb IDs

+

Found: ${response.total_missing} items missing IMDb IDs

+

TV Series: ${response.tv_series_missing} | Movies: ${response.movies_missing}

+ `; + + if (response.total_missing > 0) { + html += ` +

Recommendation: These items need manual IMDb ID assignment or folder/filename correction.

+
+ + + + + + + + + + + + + `; + + response.items.forEach(item => { + const discoveredDate = item.discovered_at ? new Date(item.discovered_at).toLocaleDateString() : 'Unknown'; + const checkCount = item.check_count || 1; + + html += ` + + + + + + + + + `; + }); + + html += ` + +
TypeFolder NameFile PathDiscoveredChecksAction
+ ${item.type} + ${item.folder_name}${item.file_path}${discoveredDate}${checkCount} + +
+
+
+

How to fix:

+ +
+ `; + } else { + html += '

✅ All items have valid IMDb IDs!

'; + } + + resultsContent.innerHTML = html; + } else { + resultsContent.innerHTML = `
Error: ${response.message}
`; + } + } catch (error) { + resultsContent.innerHTML = `
Failed to check missing IMDb items: ${error.message}
`; + } +} + async function exportDatabaseReport() { alert('Database export functionality will be implemented in a future update.'); } @@ -2092,4 +2176,30 @@ async function showRecentWebhookActivity() { } catch (error) { resultsContent.innerHTML = `
Failed to load webhook activity: ${error.message}
`; } +} + +async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + // Show a temporary success message + const button = event.target.closest('button'); + const originalIcon = button.innerHTML; + button.innerHTML = ''; + button.style.backgroundColor = '#4CAF50'; + + setTimeout(() => { + button.innerHTML = originalIcon; + button.style.backgroundColor = ''; + }, 1500); + } catch (err) { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + + alert('Path copied to clipboard!'); + } } \ No newline at end of file -- 2.39.5 From 6673ef8465a6f260171a0fa5cae9ce44d72e99b9 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:54:01 -0400 Subject: [PATCH 2/6] nfo: improvments --- api/routes.py | 34 +++++++++++++++--- api/web_routes.py | 8 +++-- nfoguard-web/static/css/styles.css | 57 ++++++++++++++++++++++++++++++ nfoguard-web/static/js/app.js | 34 ++++++++++++++---- 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/api/routes.py b/api/routes.py index cd33c43..cede28e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2725,17 +2725,43 @@ def register_routes(app, dependencies: dict): print(f" Episodes: {episodes_with_dates}/{total_episodes} found in DB, {episodes_missing_db} missing from DB") print(f" Movies: {movies_with_dates}/{total_movies} found in DB, {movies_missing_db} missing from DB") - total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) - print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") + # Calculate comprehensive statistics + total_nfo_files_missing = len(missing_tv_nfo_files) + len(missing_movie_nfo_files) + total_with_imdb_and_db = len(missing_items["episodes"]) + len(missing_items["movies"]) + + print(f"✅ NFO repair scan complete:") + print(f" 📄 {total_nfo_files_missing} NFO files missing dateadded elements") + print(f" 🎬 {len(missing_tv_nfo_files)} TV episodes, {len(missing_movie_nfo_files)} movies") + print(f" 🔍 {total_with_imdb_and_db} items with IMDb IDs found in database (can be fixed)") return { "status": "success", - "total_missing": total_missing, + "total_missing": total_with_imdb_and_db, # Items that can be fixed + "total_nfo_files_missing": total_nfo_files_missing, # All NFO files missing dateadded "episodes_missing": len(missing_items["episodes"]), "movies_missing": len(missing_items["movies"]), + "tv_nfo_files_missing": len(missing_tv_nfo_files), + "movie_nfo_files_missing": len(missing_movie_nfo_files), "total_tv_files_checked": total_tv_files_checked, "total_movie_files_checked": total_movie_files_checked, - "missing_items": missing_items + "missing_items": missing_items, + "statistics": { + "nfo_files_scanned": { + "tv": total_tv_files_checked, + "movies": total_movie_files_checked, + "total": total_tv_files_checked + total_movie_files_checked + }, + "nfo_files_missing_dateadded": { + "tv": len(missing_tv_nfo_files), + "movies": len(missing_movie_nfo_files), + "total": total_nfo_files_missing + }, + "items_with_imdb_and_database": { + "tv": len(missing_items["episodes"]), + "movies": len(missing_items["movies"]), + "total": total_with_imdb_and_db + } + } } except Exception as e: diff --git a/api/web_routes.py b/api/web_routes.py index e311934..c6ddc12 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1597,14 +1597,18 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): "total_episodes_checked": result.get('total_tv_files_checked', 0), "total_movies_checked": result.get('total_movie_files_checked', 0), "total_items_checked": result.get('total_tv_files_checked', 0) + result.get('total_movie_files_checked', 0), - "missing_dateadded_count": result['total_missing'], + "missing_dateadded_count": result['total_missing'], # Items that can be fixed + "total_nfo_files_missing": result.get('total_nfo_files_missing', result['total_missing']), # All NFO files missing dateadded + "tv_nfo_files_missing": result.get('tv_nfo_files_missing', 0), + "movie_nfo_files_missing": result.get('movie_nfo_files_missing', 0), "items": all_missing[:50], # Limit display to first 50 "debug_info": { "scan_method": "core_container_filesystem", "core_container_response": "success", "episodes_missing": result['episodes_missing'], "movies_missing": result['movies_missing'], - "core_response_keys": list(result.keys()) + "core_response_keys": list(result.keys()), + "statistics": result.get('statistics', {}) } } else: diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css index 5c46bb4..678e734 100644 --- a/nfoguard-web/static/css/styles.css +++ b/nfoguard-web/static/css/styles.css @@ -1249,6 +1249,63 @@ textarea { min-width: auto; } +/* NFO Statistics Styling */ +.nfo-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 1rem 0; +} + +.stat-box { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + text-align: center; +} + +.stat-number { + font-size: 2rem; + font-weight: 700; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +.recommendation-box { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 8px; + padding: 1rem; + margin: 1rem 0; +} + +.recommendation-box ul { + margin: 0.5rem 0 0 1rem; + padding: 0; +} + +.recommendation-box li { + margin-bottom: 0.5rem; +} + +.success-box { + background-color: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; + border-radius: 8px; + padding: 1rem; + margin: 1rem 0; + text-align: center; + font-weight: 600; +} + /* Responsive adjustments for admin tools */ @media (max-width: 768px) { .table-container { diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 6eb5b3d..09dbeeb 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -2013,13 +2013,35 @@ async function showEpisodesMissingNFODateadded() { const response = await apiCall('/api/admin/nfo/missing-dateadded'); if (response.success) { + const totalFiles = response.total_nfo_files_missing || response.missing_dateadded_count; + const fixableItems = response.missing_dateadded_count; + const tvFiles = response.tv_nfo_files_missing || 0; + const movieFiles = response.movie_nfo_files_missing || 0; + const html = ` -

Episodes Missing NFO dateadded

-

Found: ${response.missing_dateadded_count} episodes missing dateadded in NFO files

-

Total checked: ${response.total_episodes_checked} episodes

- ${response.missing_dateadded_count > 0 ? - '

Recommendation: Use the "NFO File Repair" tool to fix these issues.

' : - '

✅ All NFO files are properly maintained!

' +

NFO Files Missing dateadded Elements

+
+
+
${totalFiles}
+
Total NFO Files Missing dateadded
+
+
+
${fixableItems}
+
Can Be Fixed Automatically
+
+
+

Breakdown: ${tvFiles} TV episodes, ${movieFiles} movies

+

Scanned: ${response.total_episodes_checked} TV NFO files, ${response.total_movies_checked} movie NFO files

+ ${totalFiles > 0 ? ` +
+

📋 Action Required:

+
    +
  • ${fixableItems} items have IMDb IDs and database entries - use "NFO File Repair" tool to fix
  • + ${totalFiles > fixableItems ? `
  • ${totalFiles - fixableItems} items need IMDb IDs first - check "Items Missing IMDb IDs" tool
  • ` : ''} +
+
+ ` : + '
✅ All NFO files are properly maintained!
' } `; resultsContent.innerHTML = html; -- 2.39.5 From 6dc3dfd9ef9dcfaebb8f7a9d7f8d1a51c6d8f606 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:56:57 -0400 Subject: [PATCH 3/6] debug for movies missing .nfo --- api/routes.py | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/api/routes.py b/api/routes.py index cede28e..03f5c71 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2418,7 +2418,7 @@ def register_routes(app, dependencies: dict): print(f"📊 Found {len(missing_tv_nfo_files)} TV NFO files missing dateadded elements") # Convert TV file paths to episode information with direct NFO parsing - def extract_imdb_from_nfo_content(nfo_file, is_episode=True): + def extract_imdb_from_nfo_content(nfo_file, is_episode=True, verbose_logging=True): """Extract IMDb ID directly from NFO file content - we already know the file exists""" imdb_id = "unknown" @@ -2432,7 +2432,8 @@ def register_routes(app, dependencies: dict): if imdb_elem is not None and imdb_elem.text: imdb_text = imdb_elem.text.strip() if imdb_text.startswith('tt'): - print(f"✅ Found IMDb {imdb_text} in tag: {os.path.basename(nfo_file)}") + if verbose_logging: + print(f"✅ Found IMDb {imdb_text} in tag: {os.path.basename(nfo_file)}") return imdb_text # Method 2: Check tags @@ -2440,7 +2441,8 @@ def register_routes(app, dependencies: dict): if uniqueid.get("type") == "imdb" and uniqueid.text: imdb_text = uniqueid.text.strip() if imdb_text.startswith('tt'): - print(f"✅ Found IMDb {imdb_text} in : {os.path.basename(nfo_file)}") + if verbose_logging: + print(f"✅ Found IMDb {imdb_text} in : {os.path.basename(nfo_file)}") return imdb_text # Method 3: Check for IMDb pattern anywhere in NFO content @@ -2448,12 +2450,14 @@ def register_routes(app, dependencies: dict): content_match = re.search(r'(tt\d{7,})', nfo_content) if content_match: imdb_text = content_match.group(1) - print(f"✅ Found IMDb {imdb_text} in NFO content: {os.path.basename(nfo_file)}") + if verbose_logging: + print(f"✅ Found IMDb {imdb_text} in NFO content: {os.path.basename(nfo_file)}") return imdb_text except ET.ParseError as e: # Handle malformed XML by trying text-based search - print(f"⚠️ NFO XML parse error in {os.path.basename(nfo_file)}: {e}") + if verbose_logging: + print(f"⚠️ NFO XML parse error in {os.path.basename(nfo_file)}: {e}") try: # Fallback: Read as text and search for IMDb patterns with open(nfo_file, 'r', encoding='utf-8', errors='ignore') as f: @@ -2463,13 +2467,16 @@ def register_routes(app, dependencies: dict): text_match = re.search(r'(tt\d{7,})', nfo_text) if text_match: imdb_text = text_match.group(1) - print(f"✅ Found IMDb {imdb_text} in NFO text content: {os.path.basename(nfo_file)}") + if verbose_logging: + print(f"✅ Found IMDb {imdb_text} in NFO text content: {os.path.basename(nfo_file)}") return imdb_text except Exception as text_error: - print(f"⚠️ Error reading NFO as text {nfo_file}: {text_error}") + if verbose_logging: + print(f"⚠️ Error reading NFO as text {nfo_file}: {text_error}") except Exception as e: - print(f"⚠️ Unexpected error parsing NFO {nfo_file}: {e}") + if verbose_logging: + print(f"⚠️ Unexpected error parsing NFO {nfo_file}: {e}") # Method 4: Fallback - check folder structure if is_episode: @@ -2477,7 +2484,8 @@ def register_routes(app, dependencies: dict): folder_name = os.path.basename(series_folder) folder_match = re.search(r'\[imdb-(tt\d+)\]', folder_name, re.IGNORECASE) if folder_match: - print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}") + if verbose_logging: + print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}") return folder_match.group(1) else: # For movies, check movie folder @@ -2485,10 +2493,12 @@ def register_routes(app, dependencies: dict): folder_name = os.path.basename(movie_folder) folder_match = re.search(r'\[imdb-(tt\d+)\]', folder_name, re.IGNORECASE) if folder_match: - print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}") + if verbose_logging: + print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}") return folder_match.group(1) - print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}") + if verbose_logging: + print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}") return "unknown" for nfo_file in missing_tv_nfo_files: @@ -2600,11 +2610,19 @@ def register_routes(app, dependencies: dict): movie_folder_name = os.path.basename(movie_dir) # Extract IMDb ID from NFO content - imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=False) + imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=False, verbose_logging=False) # Clean movie title (remove year and imdb parts) clean_movie_title = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', movie_folder_name).strip() + # Log detailed movie information + if imdb_id != "unknown": + print(f"✅ Found IMDb {imdb_id} for movie: {clean_movie_title} ({movie_folder_name})") + else: + print(f"❌ No IMDb ID found for movie: {clean_movie_title} ({movie_folder_name})") + print(f" 📁 Path: {movie_dir}") + print(f" 📄 NFO: {os.path.basename(nfo_file)}") + missing_items["movies"].append({ "imdb_id": imdb_id, "title": clean_movie_title, -- 2.39.5 From 959bdba71c89478889ed9cbc8aa78892e261ff1b Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 16:00:41 -0400 Subject: [PATCH 4/6] updates --- api/routes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 03f5c71..259c8cc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2602,7 +2602,9 @@ def register_routes(app, dependencies: dict): # Process missing movie NFO files print(f"📊 Found {len(missing_movie_nfo_files)} Movie NFO files missing dateadded elements") - for nfo_file in missing_movie_nfo_files: + print(f"🎬 Processing {len(missing_movie_nfo_files)} movie NFO files for detailed information...") + for i, nfo_file in enumerate(missing_movie_nfo_files): + print(f" 📁 Movie {i+1}/{len(missing_movie_nfo_files)}: {nfo_file}") try: # Extract movie info from file path # Example: /media/Movies/movies/Knives Out (2019) [imdb-tt8946378]/movie.nfo -- 2.39.5 From 03bcd445650255de7bbee98fed9b74052b5de664 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Mon, 27 Oct 2025 09:25:47 -0400 Subject: [PATCH 5/6] improvments: updates to scans etc --- api/routes.py | 25 ++++++++++++---- clients/sonarr_client.py | 4 +++ core/nfo_manager.py | 64 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/api/routes.py b/api/routes.py index 259c8cc..602432b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -804,7 +804,9 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N if path and scan_path.name.lower().startswith('season'): # Single season processing series_path = scan_path.parent - if nfo_manager.parse_imdb_from_path(series_path): + tv_processor_obj = dependencies.get("tv_processor") + sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None + if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client): print(f"INFO: Processing single season: {scan_path}") try: tv_processor.process_season(series_path, scan_path) @@ -814,15 +816,19 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N # Single episode processing season_path = scan_path.parent series_path = season_path.parent - if nfo_manager.parse_imdb_from_path(series_path): + tv_processor_obj = dependencies.get("tv_processor") + sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None + if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client): print(f"INFO: Processing single episode: {scan_path}") try: tv_processor.process_episode_file(series_path, season_path, scan_path) except Exception as e: print(f"ERROR: Failed processing episode {scan_path}: {e}") else: - # Check if this path itself is a series (has IMDb ID in the directory name) - if nfo_manager.parse_imdb_from_path(scan_path): + # Check if this path itself is a series (has IMDb ID in directory name or NFO files) + tv_processor_obj = dependencies.get("tv_processor") + sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None + if nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client): try: # Determine force_scan based on scan mode force_scan = (scan_mode == "full") @@ -846,8 +852,10 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N not item.name.lower().startswith('season') and not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE)): - # Check for IMDb ID - imdb_id = nfo_manager.parse_imdb_from_path(item) + # Check for IMDb ID (enhanced with NFO fallback) + tv_processor_obj = dependencies.get("tv_processor") + sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None + imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(item, sonarr_client) if imdb_id: tv_series_list.append(item) else: @@ -2201,6 +2209,11 @@ def register_routes(app, dependencies: dict): @app.get("/health") async def _health() -> HealthResponse: return await health(dependencies) + + @app.get("/health/simple") + async def _health_simple(): + """Simple health check for Docker without external dependencies""" + return {"status": "healthy", "service": "nfoguard-core"} @app.get("/stats") async def _get_stats(): diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py index 8b037aa..b149eed 100644 --- a/clients/sonarr_client.py +++ b/clients/sonarr_client.py @@ -150,6 +150,10 @@ class SonarrClient: _log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}") return None + def get_series_by_id(self, series_id: int) -> Optional[Dict[str, Any]]: + """Get series information by Sonarr series ID""" + return self._get(f"/series/{series_id}") + def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]: """Get all episodes for a series""" return self._get("/episode", {"seriesId": series_id}) or [] diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 23b98fe..972582a 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -50,6 +50,58 @@ class NFOManager: return None + def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None) -> Optional[str]: + """ + Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files + + 1. First try to parse IMDb ID from directory path/name + 2. If not found, scan NFO files in directory for Sonarr series ID + 3. Use Sonarr API to lookup series and extract IMDb ID + """ + # Primary: Try directory name first + imdb_id = self.parse_imdb_from_path(path) + if imdb_id: + return imdb_id + + # Fallback: Check NFO files for Sonarr series ID + if not sonarr_client or not sonarr_client.enabled: + return None + + try: + # Look for episode NFO files in the directory (and subdirectories) + nfo_files = [] + if path.is_dir(): + # Check current directory and season subdirectories + nfo_files.extend(list(path.glob("*.nfo"))) + for subdir in path.iterdir(): + if subdir.is_dir() and subdir.name.lower().startswith('season'): + nfo_files.extend(list(subdir.glob("*.nfo"))) + + # Extract Sonarr series ID from any NFO file + for nfo_file in nfo_files: + try: + tree = ET.parse(nfo_file) + root = tree.getroot() + + # Look for Sonarr series ID + for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'): + sonarr_id = uniqueid.text + if sonarr_id and sonarr_id.isdigit(): + # Look up series in Sonarr to get IMDb ID + series_data = sonarr_client.get_series_by_id(int(sonarr_id)) + if series_data: + imdb_id = series_data.get('imdbId') + if imdb_id: + return imdb_id.replace('tt', '') if imdb_id.startswith('tt') else imdb_id + + except Exception as e: + continue # Skip this NFO file and try the next one + + except Exception as e: + pass # Fallback failed, return None + + return None + def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]: """Extract IMDb ID from NFO file content""" if not nfo_path.exists(): @@ -154,8 +206,10 @@ class NFOManager: aired_elem = root.find('.//aired') # For TV episodes lockdata_elem = root.find('.//lockdata') - # Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded) - if lockdata_elem is not None and lockdata_elem.text == "true": + # Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded + # This prevents incomplete NFO files from being marked as "complete" + if (lockdata_elem is not None and lockdata_elem.text == "true" and + dateadded_elem is not None and dateadded_elem.text): # Extract original source from NFOGuard comment, default to nfo_file_existing source = "nfo_file_existing" @@ -206,8 +260,10 @@ class NFOManager: aired_elem = root.find('.//aired') lockdata_elem = root.find('.//lockdata') - # Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded) - if lockdata_elem is not None and lockdata_elem.text == "true": + # Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded + # This prevents incomplete episode NFO files from being marked as "complete" + if (lockdata_elem is not None and lockdata_elem.text == "true" and + dateadded_elem is not None and dateadded_elem.text): # Extract original source from NFOGuard comment, default to episode_nfo_existing source = "episode_nfo_existing" -- 2.39.5 From 8dbc140b976514d1eb1de319ac649e071474cd02 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Mon, 27 Oct 2025 09:53:40 -0400 Subject: [PATCH 6/6] db: updates --- api/routes.py | 25 +++++++++++++++++++++---- core/nfo_manager.py | 14 +++++++++++++- docker-compose.example.yml | 21 ++++++++++++++++++++- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/api/routes.py b/api/routes.py index 602432b..676f3ec 100644 --- a/api/routes.py +++ b/api/routes.py @@ -806,7 +806,8 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N series_path = scan_path.parent tv_processor_obj = dependencies.get("tv_processor") sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None - if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client): + shutdown_event = dependencies.get("shutdown_event") + if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client, shutdown_event): print(f"INFO: Processing single season: {scan_path}") try: tv_processor.process_season(series_path, scan_path) @@ -818,7 +819,8 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N series_path = season_path.parent tv_processor_obj = dependencies.get("tv_processor") sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None - if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client): + shutdown_event = dependencies.get("shutdown_event") + if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client, shutdown_event): print(f"INFO: Processing single episode: {scan_path}") try: tv_processor.process_episode_file(series_path, season_path, scan_path) @@ -828,7 +830,8 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N # Check if this path itself is a series (has IMDb ID in directory name or NFO files) tv_processor_obj = dependencies.get("tv_processor") sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None - if nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client): + shutdown_event = dependencies.get("shutdown_event") + if nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client, shutdown_event): try: # Determine force_scan based on scan mode force_scan = (scan_mode == "full") @@ -848,6 +851,12 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N # Count total series first for progress tracking tv_series_list = [] for item in scan_path.iterdir(): + # Check for shutdown signal during series discovery + shutdown_event = dependencies.get("shutdown_event") + if shutdown_event and shutdown_event.is_set(): + print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping series discovery") + return + if (item.is_dir() and not item.name.lower().startswith('season') and not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE)): @@ -855,12 +864,13 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N # Check for IMDb ID (enhanced with NFO fallback) tv_processor_obj = dependencies.get("tv_processor") sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None - imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(item, sonarr_client) + imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(item, sonarr_client, shutdown_event) if imdb_id: tv_series_list.append(item) else: # Log missing IMDb ID for TV series try: + db = dependencies.get("db") db.add_missing_imdb( file_path=str(item), media_type="tv", @@ -918,6 +928,12 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N # Count total movies first for progress tracking movie_list = [] for item in scan_path.iterdir(): + # Check for shutdown signal during movie discovery + shutdown_event = dependencies.get("shutdown_event") + if shutdown_event and shutdown_event.is_set(): + print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie discovery") + return + if item.is_dir(): # Check for IMDb ID imdb_id = nfo_manager.find_movie_imdb_id(item) @@ -926,6 +942,7 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N else: # Log missing IMDb ID for movie try: + db = dependencies.get("db") db.add_missing_imdb( file_path=str(item), media_type="movie", diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 972582a..9010b0c 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -50,7 +50,7 @@ class NFOManager: return None - def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None) -> Optional[str]: + def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None, shutdown_event=None) -> Optional[str]: """ Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files @@ -63,6 +63,10 @@ class NFOManager: if imdb_id: return imdb_id + # Check for shutdown signal before expensive operations + if shutdown_event and shutdown_event.is_set(): + return None + # Fallback: Check NFO files for Sonarr series ID if not sonarr_client or not sonarr_client.enabled: return None @@ -79,6 +83,10 @@ class NFOManager: # Extract Sonarr series ID from any NFO file for nfo_file in nfo_files: + # Check for shutdown signal during NFO processing + if shutdown_event and shutdown_event.is_set(): + return None + try: tree = ET.parse(nfo_file) root = tree.getroot() @@ -87,6 +95,10 @@ class NFOManager: for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'): sonarr_id = uniqueid.text if sonarr_id and sonarr_id.isdigit(): + # Check for shutdown signal before API call + if shutdown_event and shutdown_event.is_set(): + return None + # Look up series in Sonarr to get IMDb ID series_data = sonarr_client.get_series_by_id(int(sonarr_id)) if series_data: diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 71258f1..0a32f0f 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -122,4 +122,23 @@ networks: # - Web interface operations don't impact core processing # - Webhooks remain responsive during long scans # - Independent scaling and resource allocation -# - Separated concerns for maintenance and updates \ No newline at end of file +# - Separated concerns for maintenance and updates + # NFOGuard Core (Processing Engine) + nfoguard: + # ... other settings ... + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/simple"] + interval: 30s + timeout: 15s # Increased from 10s + retries: 3 + start_period: 60s # Increased from 40s + + # NFOGuard Web Interface + nfoguard-web: + # ... other settings ... + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/health"] + interval: 30s + timeout: 15s # Increased from 10s + retries: 3 + start_period: 30s # Increased from 10s \ No newline at end of file -- 2.39.5