From 6946ce20821f26a3f71652111be578f721183a96 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Thu, 23 Oct 2025 09:56:35 -0400 Subject: [PATCH 01/56] fix: nfoguard info being put at top --- api/routes.py | 21 +++++++++++++++++++-- core/episode_nfo_manager.py | 2 +- core/nfo_manager.py | 26 +++++++++----------------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/api/routes.py b/api/routes.py index 74b657e..142a83d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -107,9 +107,26 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de if not episodes_data and webhook.episodeFile: episode_file = webhook.episodeFile - # Extract season and episode from episodeFile if available + # Extract season and episode from episodeFile path/filename season_num = episode_file.get("seasonNumber") - episode_num = episode_file.get("episodeNumber") + episode_num = episode_file.get("episodeNumber") + + # If not directly available, parse from relativePath or path + if not (season_num and episode_num): + from utils.nfo_patterns import extract_episode_info_from_filename + + # Try relativePath first, then path + file_path = episode_file.get("relativePath") or episode_file.get("path", "") + print(f"DEBUG: Parsing episode info from path: {file_path}") + + episode_info = extract_episode_info_from_filename(file_path) + if episode_info: + season_num = episode_info["season"] + episode_num = episode_info["episode"] + print(f"DEBUG: Extracted from filename - Season: {season_num}, Episode: {episode_num}") + else: + print(f"DEBUG: Could not extract season/episode from filename: {file_path}") + print(f"DEBUG: episodeFile seasonNumber: {season_num}, episodeNumber: {episode_num}") if season_num and episode_num: # Create episode data structure that matches what process_webhook_episodes expects diff --git a/core/episode_nfo_manager.py b/core/episode_nfo_manager.py index b6823d4..24b1858 100644 --- a/core/episode_nfo_manager.py +++ b/core/episode_nfo_manager.py @@ -220,7 +220,7 @@ class EpisodeNFOManager: lockdata_elem = ET.SubElement(episode_elem, "lockdata") lockdata_elem.text = "true" - # Add comment with source + # Add comment with source at the bottom comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ") episode_elem.append(comment) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index b03507e..5dd4854 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -342,10 +342,6 @@ class NFOManager: # This ensures they appear as a group at the bottom of the file nfoguard_elements = [] - # Add NFOGuard comment marker as the first of our additions - nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ") - nfoguard_elements.append(nfoguard_comment) - # Add IMDb uniqueid uniqueid = ET.Element("uniqueid", type="imdb", default="true") uniqueid.text = imdb_id @@ -384,6 +380,10 @@ class NFOManager: lockdata.text = "true" nfoguard_elements.append(lockdata) + # Add NFOGuard comment as the very last element (appears at bottom) + nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ") + nfoguard_elements.append(nfoguard_comment) + # Now append all NFOGuard elements to the movie in one batch # This ensures they appear as a contiguous block at the bottom for elem in nfoguard_elements: @@ -395,7 +395,7 @@ class NFOManager: tree = ET.ElementTree(movie) ET.indent(tree, space=" ", level=0) - # Write directly to file (comment is already embedded in XML) + # Write directly to file (comment is already embedded in XML at bottom) with open(nfo_path, 'w', encoding='utf-8') as f: f.write('\n') tree.write(f, encoding='unicode', xml_declaration=False) @@ -455,22 +455,14 @@ class NFOManager: lockdata = ET.SubElement(tvshow, "lockdata") lockdata.text = "true" - # Add NFOGuard comment at the beginning - comment_text = f" Created by {self.manager_brand} " + # Add NFOGuard comment at the bottom + comment = ET.Comment(f" Created by {self.manager_brand} ") + tvshow.append(comment) # Write file with proper formatting tree = ET.ElementTree(tvshow) ET.indent(tree, space=" ", level=0) - - # Write to string first to add comment - xml_str = ET.tostring(tvshow, encoding='unicode') - - # Add XML declaration and comment - full_xml = f'\n\n{xml_str}' - - # Write to file - with open(nfo_path, 'w', encoding='utf-8') as f: - f.write(full_xml) + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) print(f"✅ Successfully created/updated TV show NFO: {nfo_path}") print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else "")) From 7fb07fcddf4e5763f0670c58bd6500c58758c665 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Thu, 23 Oct 2025 10:53:35 -0400 Subject: [PATCH 02/56] fix: nfoguard data at bottom of file --- core/nfo_manager.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 5dd4854..3ba6cf8 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -509,22 +509,14 @@ class NFOManager: lockdata = ET.SubElement(season, "lockdata") lockdata.text = "true" - # Add NFOGuard comment at the beginning - comment_text = f" Created by {self.manager_brand} " + # Add NFOGuard comment at the bottom + comment = ET.Comment(f" Created by {self.manager_brand} ") + season.append(comment) # Write file with proper formatting tree = ET.ElementTree(season) ET.indent(tree, space=" ", level=0) - - # Write to string first to add comment - xml_str = ET.tostring(season, encoding='unicode') - - # Add XML declaration and comment - full_xml = f'\n\n{xml_str}' - - # Write to file - with open(nfo_path, 'w', encoding='utf-8') as f: - f.write(full_xml) + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) print(f"✅ Successfully created/updated season NFO: {nfo_path}") print(f" Season: {season_number}") @@ -709,22 +701,14 @@ class NFOManager: lockdata = ET.SubElement(episode, "lockdata") lockdata.text = "true" - # Add NFOGuard comment at the beginning - comment_text = f" Created by {self.manager_brand} - Source: {source} " + # Add NFOGuard comment at the bottom + comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ") + episode.append(comment) # Write file with proper formatting tree = ET.ElementTree(episode) ET.indent(tree, space=" ", level=0) - - # Write to string first to add comment - xml_str = ET.tostring(episode, encoding='unicode') - - # Add XML declaration and comment - full_xml = f'\n\n{xml_str}' - - # Write to file - with open(nfo_path, 'w', encoding='utf-8') as f: - f.write(full_xml) + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) print(f"✅ Successfully created/updated episode NFO: {nfo_path}") print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}") From f6c5ccd2b619087dc594ad4ab8769c72747e416c Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Fri, 24 Oct 2025 13:26:58 -0400 Subject: [PATCH 03/56] web: heatlh check --- docker-compose.example.yml | 2 +- start_web.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 8240232..71258f1 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -88,7 +88,7 @@ services: nfoguard: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8081/"] + test: ["CMD", "curl", "-f", "http://localhost:8081/health"] interval: 30s timeout: 10s retries: 3 diff --git a/start_web.py b/start_web.py index 7d0cfdd..a599e1a 100644 --- a/start_web.py +++ b/start_web.py @@ -5,6 +5,7 @@ Simple script to start web interface using existing config system """ import os import sys +import time import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles @@ -82,6 +83,22 @@ def setup_static_files(app: FastAPI) -> None: # Return 204 No Content if no favicon found from fastapi import Response return Response(status_code=204) + + # Health check endpoint for Docker + @app.get("/health") + async def health_check(): + """Health check endpoint for Docker container monitoring""" + try: + # Basic health check - verify the web service is responsive + return { + "status": "healthy", + "service": "nfoguard-web", + "timestamp": time.time(), + "version": "2.8.0-web" + } + except Exception as e: + from fastapi import HTTPException + raise HTTPException(status_code=503, detail=f"Health check failed: {e}") def main(): From e054c945ff71ec7019ecf71bf753270562f680fd Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Fri, 24 Oct 2025 16:30:08 -0400 Subject: [PATCH 04/56] feat: add support for Maintainarr webhook --- MAINTAINARR_WEBHOOK.md | 196 +++++++++++++++++++++++++++++++++++++++++ api/models.py | 12 +++ api/routes.py | 167 ++++++++++++++++++++++++++++++++++- core/database.py | 23 +++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 MAINTAINARR_WEBHOOK.md diff --git a/MAINTAINARR_WEBHOOK.md b/MAINTAINARR_WEBHOOK.md new file mode 100644 index 0000000..039df40 --- /dev/null +++ b/MAINTAINARR_WEBHOOK.md @@ -0,0 +1,196 @@ +# Maintainarr Webhook Integration + +This document describes how to configure NFOGuard to receive webhooks from Maintainarr for automatic database cleanup when media is removed. + +## Overview + +When Maintainarr removes media from your Plex/Radarr/Sonarr collections, NFOGuard can automatically remove the corresponding entries from its database to keep it clean and up-to-date. + +## Webhook Configuration + +### 1. NFOGuard Endpoint + +The webhook endpoint is available at: +``` +http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr +``` + +**Important**: Use the core NFOGuard container port (typically 8080), not the web interface port (8081). + +### 2. Maintainarr Configuration + +In Maintainarr, create a new webhook notification agent with these settings: + +#### Basic Settings +- **Name**: `NFOGuard Cleanup` +- **Enabled**: ✅ Checked +- **Agent**: `Webhook` + +#### Webhook Configuration +- **Webhook URL**: `http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr` +- **JSON Payload**: Use the template below +- **Auth Header**: *(Leave empty - no authentication required)* + +#### JSON Payload Template +Copy and paste this JSON template into the "Json Payload" field: + +```json +{ + "notification_type": "{{notification_type}}", + "subject": "{{subject}}", + "message": "{{message}} - IMDb: {{extra}}", + "extra": "{{extra}}" +} +``` + +**Important**: Make sure to include the IMDb ID in either the `{{message}}` or `{{extra}}` fields for NFOGuard to identify the media. The webhook handler will extract the IMDb ID from these fields. + +#### Event Types +Select these notification types: +- ✅ **Media Removed From Collection** - Removes media from NFOGuard database +- ✅ **Media About To Be Handled** - Optional: Log when media is about to be processed + +Optional event types (will be logged but not processed): +- ☐ Media Added To Collection +- ☐ Media Handled +- ☐ Rule Handling Failed +- ☐ Collection Handling Failed + +## Supported Operations + +### Movies +When a movie is removed from Maintainarr: +- NFOGuard checks if the movie exists in its database (by IMDb ID) +- If found, removes the movie record from the `movies` table +- Logs the deletion operation + +### TV Series +When a TV series is removed from Maintainarr: +- NFOGuard checks if the series exists in its database (by IMDb ID) +- If found, removes all episode records from the `episodes` table +- Removes the series record from the `series` table +- Logs the deletion operation with episode count + +## Webhook Payload + +Maintainarr sends webhook payloads using template variables that you configure: + +```json +{ + "notification_type": "Media Removed", + "subject": "Example Movie (2023)", + "message": "Removed movie Example Movie from collection Action Movies - IMDb: tt1234567", + "extra": "tt1234567" +} +``` + +### How It Works +1. **Maintainarr** populates the template variables ({{notification_type}}, {{subject}}, {{message}}, {{extra}}) +2. **NFOGuard** receives the webhook and parses the content to extract: + - **IMDb ID**: Extracted from `message`, `subject`, or `extra` fields using pattern matching + - **Media Type**: Determined from message content keywords or database lookup + - **Title**: Extracted from `subject` or `message` fields + +### Media Identification +NFOGuard looks for IMDb IDs in this format: +- `tt1234567` (preferred) +- `1234567` (will be converted to tt1234567) + +The webhook handler uses intelligent parsing to: +- Extract IMDb IDs from any field using regex patterns +- Determine if media is a Movie or Series based on keywords or database lookup +- Extract the media title from subject or message content + +## Response Format + +NFOGuard responds with JSON indicating the result: + +### Success Response +```json +{ + "status": "success", + "message": "Processed Media Removed for Example Movie", + "media_type": "Movie", + "imdb_id": "tt1234567", + "removed_count": 1, + "removed_items": ["Movie: Example Movie (tt1234567)"] +} +``` + +### Ignored Response +```json +{ + "status": "ignored", + "reason": "Media tt1234567 not found in database" +} +``` + +### Error Response +```json +{ + "status": "error", + "message": "No IMDb ID found in webhook payload" +} +``` + +## Logging + +All webhook activities are logged with details: + +``` +INFO: Received Maintainarr webhook: Media Removed +INFO: Processing movie deletion for Example Movie (tt1234567) +SUCCESS: Removed movie Example Movie (tt1234567) from database +INFO: Maintainarr cleanup: Media Removed - Movie 'Example Movie' (tt1234567). Removed from database: Movie: Example Movie (tt1234567) +``` + +## Troubleshooting + +### Common Issues + +1. **Media Not Found**: + - Check if the media exists in NFOGuard's database + - Verify the IMDb ID matches between Maintainarr and NFOGuard + +2. **Connection Issues**: + - Ensure NFOGuard core container is accessible on port 8080 + - Check firewall settings and network connectivity + +3. **Authentication Errors**: + - No authentication is required for the webhook endpoint + - Ensure you're using the core container port, not web interface port + +### Testing the Webhook + +You can test the webhook manually using curl: + +```bash +curl -X POST http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr \ + -H "Content-Type: application/json" \ + -d '{ + "notification_type": "Media Removed", + "subject": "Test Movie (2023)", + "message": "Removed movie Test Movie from collection - IMDb: tt1234567", + "extra": "tt1234567" + }' +``` + +## Security Considerations + +- The webhook endpoint does not require authentication +- Consider using firewalls or network restrictions to limit access +- The endpoint only processes deletion requests, not additions +- All operations are logged for audit purposes + +## Integration Benefits + +- **Automatic Cleanup**: Keeps NFOGuard database synchronized with your media collection +- **Accurate Statistics**: Dashboard stats reflect only currently available media +- **Reduced Manual Maintenance**: No need to manually clean up orphaned entries +- **Audit Trail**: All deletions are logged with full details + +## Version Compatibility + +- NFOGuard: v2.8.0+ +- Maintainarr: All versions with webhook support +- Requires NFOGuard core container (processing container), not web-only container \ No newline at end of file diff --git a/api/models.py b/api/models.py index 73a27da..d604b55 100644 --- a/api/models.py +++ b/api/models.py @@ -31,6 +31,18 @@ class RadarrWebhook(BaseModel): extra = "allow" +class MaintainarrWebhook(BaseModel): + """Maintainarr webhook payload model - uses template variables""" + notification_type: Optional[str] = None # e.g., "Media Removed" + subject: Optional[str] = None + message: Optional[str] = None + image: Optional[str] = None + extra: Optional[str] = None + + class Config: + extra = "allow" + + class HealthResponse(BaseModel): """Health check response model""" status: str diff --git a/api/routes.py b/api/routes.py index 142a83d..2da3adf 100644 --- a/api/routes.py +++ b/api/routes.py @@ -12,7 +12,7 @@ from typing import Optional # Import models from api.models import ( - SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest, + SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest, MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest ) # Web routes removed - handled by separate web container @@ -327,6 +327,167 @@ async def radarr_webhook(request: Request, background_tasks: BackgroundTasks, de return {"status": "error", "message": str(e)} +async def maintainarr_webhook(request: Request, background_tasks: BackgroundTasks, dependencies: dict): + """Handle Maintainarr webhooks for media deletion""" + db = dependencies["db"] + config = dependencies["config"] + + try: + payload = await _read_payload(request) + if not payload: + raise HTTPException(status_code=422, detail="Empty Maintainarr payload") + + webhook = MaintainarrWebhook(**payload) + print(f"INFO: Received Maintainarr webhook: {webhook.notification_type}") + print(f"DEBUG: Full Maintainarr webhook payload: {payload}") + + # Only process media removal notifications + notification_type = webhook.notification_type or "" + if "removed" not in notification_type.lower() and "delete" not in notification_type.lower(): + return {"status": "ignored", "reason": f"Notification type '{notification_type}' not processed"} + + # Parse message to extract media information + message = webhook.message or "" + subject = webhook.subject or "" + extra = webhook.extra or "" + + # Try to extract IMDb ID from message, subject, or extra data + import re + imdb_pattern = r'tt\d{7,8}|\b\d{7,8}\b' + + imdb_id = None + title = "Unknown Media" + media_type = "Unknown" + + # Look for IMDb ID in all fields + for field in [message, subject, extra]: + if field: + imdb_matches = re.findall(imdb_pattern, field) + if imdb_matches: + imdb_id = imdb_matches[0] + if not imdb_id.startswith("tt"): + imdb_id = f"tt{imdb_id}" + break + + if not imdb_id: + print(f"WARNING: No IMDb ID found in Maintainarr webhook - Message: '{message}', Subject: '{subject}'") + return {"status": "ignored", "reason": "No IMDb ID found in webhook payload"} + + # Try to extract title from subject or message + if subject and subject.strip(): + title = subject.strip() + elif message and message.strip(): + # Extract title from message if possible + title_match = re.search(r'(?:movie|series|show)\s*[:\-]?\s*(.+?)(?:\s*\(|$)', message, re.IGNORECASE) + if title_match: + title = title_match.group(1).strip() + else: + title = message.strip() + + # Try to determine media type from message content + if any(word in message.lower() for word in ["series", "show", "tv", "season", "episode"]): + media_type = "Series" + elif any(word in message.lower() for word in ["movie", "film"]): + media_type = "Movie" + else: + # Try both types - first check if it's a series, then movie + series = db.get_series_by_imdb(imdb_id) + movie = db.get_movie_by_imdb(imdb_id) + + if series: + media_type = "Series" + elif movie: + media_type = "Movie" + else: + print(f"INFO: Media {title} ({imdb_id}) not found in database") + return {"status": "ignored", "reason": f"Media {imdb_id} not found in database"} + + # Process deletion based on media type + removed_count = 0 + removed_items = [] + + if media_type == "Movie": + print(f"INFO: Processing movie deletion for {title} ({imdb_id})") + + # Check if movie exists in database + movie = db.get_movie_by_imdb(imdb_id) + if movie: + if db.delete_movie(imdb_id): + removed_count += 1 + removed_items.append(f"Movie: {title} ({imdb_id})") + print(f"SUCCESS: Removed movie {title} ({imdb_id}) from database") + else: + print(f"WARNING: Failed to remove movie {title} ({imdb_id}) from database") + else: + print(f"INFO: Movie {title} ({imdb_id}) not found in database") + + elif media_type == "Series": + print(f"INFO: Processing series deletion for {title} ({imdb_id})") + + # Check if series exists in database + series = db.get_series_by_imdb(imdb_id) + if series: + # Delete all episodes for the series + episodes_removed = db.delete_series_episodes(imdb_id) + if episodes_removed > 0: + removed_count += episodes_removed + removed_items.append(f"Series episodes: {title} ({imdb_id}) - {episodes_removed} episodes") + print(f"SUCCESS: Removed {episodes_removed} episodes for series {title} ({imdb_id})") + + # Delete the series record + if db.delete_series(imdb_id): + removed_count += 1 + removed_items.append(f"Series: {title} ({imdb_id})") + print(f"SUCCESS: Removed series {title} ({imdb_id}) from database") + else: + print(f"WARNING: Failed to remove series {title} ({imdb_id}) from database") + else: + print(f"INFO: Series {title} ({imdb_id}) not found in database") + + # Log the cleanup operation + if removed_count > 0: + background_tasks.add_task( + _log_maintainarr_cleanup, + notification_type, + media_type, + title, + imdb_id, + removed_items, + webhook.subject + ) + + return { + "status": "success", + "message": f"Processed {notification_type} for {title}", + "media_type": media_type, + "imdb_id": imdb_id, + "removed_count": removed_count, + "removed_items": removed_items + } + + except Exception as e: + print(f"ERROR: Maintainarr webhook error: {e}") + import traceback + print(f"ERROR: Traceback: {traceback.format_exc()}") + return {"status": "error", "message": str(e)} + + +async def _log_maintainarr_cleanup(event_type: str, media_type: str, title: str, imdb_id: str, removed_items: list, collection_name: str = None): + """Background task to log Maintainarr cleanup operations""" + try: + log_message = f"Maintainarr cleanup: {event_type} - {media_type} '{title}' ({imdb_id})" + if collection_name: + log_message += f" from collection '{collection_name}'" + log_message += f". Removed from database: {', '.join(removed_items)}" + + print(f"INFO: {log_message}") + + # Could extend this to write to a cleanup log file or database table + + except Exception as e: + print(f"ERROR: Failed to log Maintainarr cleanup: {e}") + + async def health(dependencies: dict) -> HealthResponse: """Health check endpoint with Radarr database status""" db = dependencies["db"] @@ -1933,6 +2094,10 @@ def register_routes(app, dependencies: dict): async def _radarr_webhook(request: Request, background_tasks: BackgroundTasks): return await radarr_webhook(request, background_tasks, dependencies) + @app.post("/webhook/maintainarr") + async def _maintainarr_webhook(request: Request, background_tasks: BackgroundTasks): + return await maintainarr_webhook(request, background_tasks, dependencies) + @app.get("/health") async def _health() -> HealthResponse: return await health(dependencies) diff --git a/core/database.py b/core/database.py index 13238cb..efaadb5 100644 --- a/core/database.py +++ b/core/database.py @@ -441,6 +441,29 @@ class NFOGuardDatabase: return deleted_count > 0 + def delete_series(self, imdb_id: str) -> bool: + """ + Delete a specific series from the database + + Args: + imdb_id: Series IMDb ID + + Returns: + True if series was deleted, False if not found + """ + with self.get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + DELETE FROM series + WHERE imdb_id = %s + """, (imdb_id,)) + + deleted_count = cursor.rowcount + conn.commit() + + return deleted_count > 0 + def delete_orphaned_movies(self) -> List[Dict]: """ Find and delete movies that don't have corresponding video files on disk From 7b62d737535a638cab122522948f4eb98d0de811 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Fri, 24 Oct 2025 16:35:11 -0400 Subject: [PATCH 05/56] feat: update maintarr payload --- MAINTAINARR_WEBHOOK.md | 21 ++++++++++++++++----- api/routes.py | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/MAINTAINARR_WEBHOOK.md b/MAINTAINARR_WEBHOOK.md index 039df40..0adb287 100644 --- a/MAINTAINARR_WEBHOOK.md +++ b/MAINTAINARR_WEBHOOK.md @@ -38,12 +38,14 @@ Copy and paste this JSON template into the "Json Payload" field: { "notification_type": "{{notification_type}}", "subject": "{{subject}}", - "message": "{{message}} - IMDb: {{extra}}", + "message": "{{message}}", "extra": "{{extra}}" } ``` -**Important**: Make sure to include the IMDb ID in either the `{{message}}` or `{{extra}}` fields for NFOGuard to identify the media. The webhook handler will extract the IMDb ID from these fields. +**Important Note**: Maintainarr's template variables may not include IMDb IDs directly. The webhook will attempt to extract IMDb IDs from the notification content, but this may require manual configuration or rule setup in Maintainarr to include IMDb IDs in the notification text. + +**Alternative Approach**: If Maintainarr doesn't provide IMDb IDs in notifications, you may need to use NFOGuard's manual cleanup tools or configure Maintainarr rules to include IMDb information in the message content. #### Event Types Select these notification types: @@ -148,18 +150,27 @@ INFO: Maintainarr cleanup: Media Removed - Movie 'Example Movie' (tt1234567). Re ### Common Issues -1. **Media Not Found**: +1. **No IMDb ID Found**: + - Maintainarr template variables may not include IMDb IDs + - Check if the notification message contains IMDb information + - You may need to manually include IMDb IDs in Maintainarr rule configurations + +2. **Media Not Found**: - Check if the media exists in NFOGuard's database - Verify the IMDb ID matches between Maintainarr and NFOGuard -2. **Connection Issues**: +3. **Connection Issues**: - Ensure NFOGuard core container is accessible on port 8080 - Check firewall settings and network connectivity -3. **Authentication Errors**: +4. **Authentication Errors**: - No authentication is required for the webhook endpoint - Ensure you're using the core container port, not web interface port +5. **Test Notifications**: + - Test notifications (like the one you just sent) will be acknowledged but not processed + - Real media removal events will trigger the cleanup process + ### Testing the Webhook You can test the webhook manually using curl: diff --git a/api/routes.py b/api/routes.py index 2da3adf..a1f8876 100644 --- a/api/routes.py +++ b/api/routes.py @@ -341,8 +341,22 @@ async def maintainarr_webhook(request: Request, background_tasks: BackgroundTask print(f"INFO: Received Maintainarr webhook: {webhook.notification_type}") print(f"DEBUG: Full Maintainarr webhook payload: {payload}") - # Only process media removal notifications + # Handle test notifications differently for debugging notification_type = webhook.notification_type or "" + if notification_type == "TEST_NOTIFICATION": + return { + "status": "test_received", + "message": "Test notification received successfully", + "available_fields": { + "notification_type": webhook.notification_type, + "subject": webhook.subject, + "message": webhook.message, + "extra": webhook.extra + }, + "debug": "This is a test notification. Real media removal events will be processed when they occur." + } + + # Only process media removal notifications if "removed" not in notification_type.lower() and "delete" not in notification_type.lower(): return {"status": "ignored", "reason": f"Notification type '{notification_type}' not processed"} From 1314ee3e709e138681ba69fc544660016f8512a0 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 12:02:15 -0400 Subject: [PATCH 06/56] debug: sonarr webhook issues --- core/nfo_manager.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/nfo_manager.py b/core/nfo_manager.py index 3ba6cf8..23b98fe 100644 --- a/core/nfo_manager.py +++ b/core/nfo_manager.py @@ -691,10 +691,15 @@ class NFOManager: aired_str = str(aired) aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str + # Debug logging for dateadded + print(f"🔍 DEBUG: dateadded value: {repr(dateadded)} (type: {type(dateadded)})") if dateadded: dateadded_elem = ET.SubElement(episode, "dateadded") # Convert datetime objects to strings dateadded_elem.text = str(dateadded) + print(f"✅ Added dateadded to episode NFO: {dateadded}") + else: + print(f"❌ dateadded is empty/None, not adding to episode NFO") # Add lockdata at the very end if lock_metadata: From ab48d485b855d289b3b0247e05cf221b4b231790 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 12:09:23 -0400 Subject: [PATCH 07/56] fix: docker hangs --- core/database.py | 12 +++++++++++- main.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/core/database.py b/core/database.py index efaadb5..088f114 100644 --- a/core/database.py +++ b/core/database.py @@ -587,7 +587,17 @@ class NFOGuardDatabase: """Close all database connections""" if hasattr(self._local, 'connection'): try: + # For PostgreSQL, ensure all transactions are committed/rolled back + try: + # Force rollback any open transactions + self._local.connection.rollback() + except Exception: + pass + + # Close the connection self._local.connection.close() delattr(self._local, 'connection') - except Exception: + print("✅ Database connection closed successfully") + except Exception as e: + print(f"⚠️ Error closing database connection: {e}") pass # Connection may already be closed \ No newline at end of file diff --git a/main.py b/main.py index 5ca49c0..97eb35a 100644 --- a/main.py +++ b/main.py @@ -146,6 +146,18 @@ def signal_handler(signum, frame): _log("WARNING", f"Error closing database: {e}") _log("INFO", "Graceful shutdown complete") + + # Force exit after 2 seconds if graceful shutdown doesn't work + import threading + def force_exit(): + import time + time.sleep(2) + _log("WARNING", "Force exiting after timeout") + os._exit(0) + + force_thread = threading.Thread(target=force_exit, daemon=True) + force_thread.start() + sys.exit(0) From 47311864cf7c4461b052c10509abf2710be5c900 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 12:22:12 -0400 Subject: [PATCH 08/56] disable: remove season.nfo and tvshow.nfo from being created --- processors/tv_processor.py | 28 +++++++++++------------ processors/tv_series_processor.py | 37 ++++++++++++++++--------------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/processors/tv_processor.py b/processors/tv_processor.py index a3f3ac3..957f7c6 100644 --- a/processors/tv_processor.py +++ b/processors/tv_processor.py @@ -825,20 +825,20 @@ class TVProcessor: episodes_processed += 1 - # Create season/tvshow NFOs if any episodes were processed - if episodes_processed > 0 and config.manage_nfo: - seasons_processed = set() - for webhook_episode in webhook_episodes: - season_num = webhook_episode.get("seasonNumber") - if season_num and season_num not in seasons_processed: - season_dir = series_path / config.tv_season_dir_format.format(season=season_num) - if season_dir.exists(): - self.nfo_manager.create_season_nfo(season_dir, season_num) - seasons_processed.add(season_num) - - # Get TVDB ID for better Emby compatibility - tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) - self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) + # DISABLED: Skip creating season.nfo and tvshow.nfo files (user only wants episode NFOs) + # if episodes_processed > 0 and config.manage_nfo: + # seasons_processed = set() + # for webhook_episode in webhook_episodes: + # season_num = webhook_episode.get("seasonNumber") + # if season_num and season_num not in seasons_processed: + # season_dir = series_path / config.tv_season_dir_format.format(season=season_num) + # if season_dir.exists(): + # self.nfo_manager.create_season_nfo(season_dir, season_num) + # seasons_processed.add(season_num) + # + # # Get TVDB ID for better Emby compatibility + # tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id) + # self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id) _log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed") diff --git a/processors/tv_series_processor.py b/processors/tv_series_processor.py index c30189a..a3e01ec 100644 --- a/processors/tv_series_processor.py +++ b/processors/tv_series_processor.py @@ -371,21 +371,22 @@ class TVSeriesProcessor: return None, "no_external_data" def _create_series_nfos(self, series_path: Path, imdb_id: str): - """Create tvshow.nfo and season.nfo files only if they don't exist""" - # Create tvshow.nfo only if it doesn't exist - tvshow_nfo = series_path / "tvshow.nfo" - if not tvshow_nfo.exists(): - self.nfo_manager.create_tvshow_nfo(series_path, imdb_id) - else: - _log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}") - - # Create season.nfo for each season directory only if they don't exist - for season_dir in series_path.iterdir(): - if season_dir.is_dir() and self._is_season_directory(season_dir.name): - season_num = self._extract_season_number(season_dir.name) - if season_num is not None: - season_nfo = season_dir / "season.nfo" - if not season_nfo.exists(): - self.nfo_manager.create_season_nfo(season_dir, season_num) - else: - _log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}") \ No newline at end of file + """DISABLED: Skip creating tvshow.nfo and season.nfo files (user only wants episode NFOs)""" + # # Create tvshow.nfo only if it doesn't exist + # tvshow_nfo = series_path / "tvshow.nfo" + # if not tvshow_nfo.exists(): + # self.nfo_manager.create_tvshow_nfo(series_path, imdb_id) + # else: + # _log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}") + # + # # Create season.nfo for each season directory only if they don't exist + # for season_dir in series_path.iterdir(): + # if season_dir.is_dir() and self._is_season_directory(season_dir.name): + # season_num = self._extract_season_number(season_dir.name) + # if season_num is not None: + # season_nfo = season_dir / "season.nfo" + # if not season_nfo.exists(): + # self.nfo_manager.create_season_nfo(season_dir, season_num) + # else: + # _log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}") + pass # Function disabled - only process episode NFOs \ No newline at end of file From 2811d30352ab05d3d22a1e0cd37c0a599bef72e0 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 12:27:52 -0400 Subject: [PATCH 09/56] fix: not using airdate when rename is first event in sonarr --- clients/sonarr_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py index 0985662..7805383 100644 --- a/clients/sonarr_client.py +++ b/clients/sonarr_client.py @@ -222,7 +222,7 @@ class SonarrClient: import_date = earliest_import["date"] _log("INFO", f"Found import date: {import_date} for episode {episode_id}") - # Check if this looks like an upgrade by comparing to renames + # Check if this import is likely a recent re-download/upgrade if rename_events: earliest_rename = min(rename_events, key=lambda x: x["date"]) rename_date = earliest_rename["date"] @@ -232,10 +232,12 @@ class SonarrClient: rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00")) days_diff = (import_dt - rename_dt).days - # If import is significantly after rename, prefer rename date + # If import is significantly after rename, this is likely a re-download + # The original import likely happened much earlier (possibly lost from history) + # Return None to trigger aired date fallback instead of using rename date if days_diff > 30: - _log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date") - return rename_date + _log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - this looks like a re-download, using aired date fallback") + return None # Trigger aired date fallback except Exception as e: _log("DEBUG", f"Error comparing dates: {e}") From 4c4a805c5df265386965461bf310c5409cbc7db3 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 12:30:44 -0400 Subject: [PATCH 10/56] fix: rename and airdate fixes --- clients/sonarr_client.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/clients/sonarr_client.py b/clients/sonarr_client.py index 7805383..8b037aa 100644 --- a/clients/sonarr_client.py +++ b/clients/sonarr_client.py @@ -222,7 +222,7 @@ class SonarrClient: import_date = earliest_import["date"] _log("INFO", f"Found import date: {import_date} for episode {episode_id}") - # Check if this import is likely a recent re-download/upgrade + # Check chronological order of events if rename_events: earliest_rename = min(rename_events, key=lambda x: x["date"]) rename_date = earliest_rename["date"] @@ -230,13 +230,15 @@ class SonarrClient: try: import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00")) rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00")) - days_diff = (import_dt - rename_dt).days - # If import is significantly after rename, this is likely a re-download - # The original import likely happened much earlier (possibly lost from history) - # Return None to trigger aired date fallback instead of using rename date - if days_diff > 30: - _log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - this looks like a re-download, using aired date fallback") + # If import happened BEFORE rename, it's valid original import + if import_dt <= rename_dt: + _log("INFO", f"Import {import_date} happened before/during rename {rename_date} - using import date") + return import_date + + # If rename happened BEFORE import - always use aired date fallback + else: + _log("WARNING", f"Rename {rename_date} happened before import {import_date} - using aired date fallback") return None # Trigger aired date fallback except Exception as e: From 952319b06120498eb807ee80af6c3b3badf78e01 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 13:07:57 -0400 Subject: [PATCH 11/56] fixes: dates sonarr --- api/routes.py | 1 + processors/tv_processor.py | 52 +++++++++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/api/routes.py b/api/routes.py index a1f8876..e46409d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -135,6 +135,7 @@ async def sonarr_webhook(request: Request, background_tasks: BackgroundTasks, de "episodeNumber": episode_num, "id": episode_file.get("id"), "title": episode_file.get("title") + # Note: Not including dateAdded - we use database-first approach with Sonarr fallback }] print(f"INFO: Extracted episode info from episodeFile for {webhook.eventType}: S{season_num:02d}E{episode_num:02d}") else: diff --git a/processors/tv_processor.py b/processors/tv_processor.py index 957f7c6..3859fd7 100644 --- a/processors/tv_processor.py +++ b/processors/tv_processor.py @@ -797,7 +797,7 @@ class TVProcessor: # Get episode date information - webhook processing prioritizes existing DB entries _log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}") - aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata) + aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode) enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir) # Create NFO @@ -942,17 +942,46 @@ class TVProcessor: return None - def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: + def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None, webhook_episode: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]: """ - Get episode date for webhook processing - avoid treating webhook as gospel. + Get episode date for webhook processing - database-first approach. Logic: - 1. Check Sonarr import history FIRST (any history = show has existed for a while) - 2. If ANY import history exists → Use air date (webhook is likely upgrade/rename) - 3. If NO import history → Use webhook date (truly new show) + 1. Check NFOGuard database FIRST - if we have a date, use it (no re-processing) + 2. No date in DB? Check Sonarr import history with our rules: + - Import before rename = use import date + - Rename before import = use airdate + - First import matches webhook = valid new episode + 3. Final fallback = airdate - This prevents upgrades from overriding dates for shows you've had for months/years. + This prevents webhooks from overriding existing good data. """ + + # STEP 1: Check NFOGuard database first + existing_entry = None + existing_date = None + try: + existing_entry = self.db.get_episode(imdb_id, season_num, episode_num) + if existing_entry and existing_entry.get('date_added'): + existing_date = existing_entry['date_added'] + _log("INFO", f"Found existing date in database for S{season_num:02d}E{episode_num:02d}: {existing_date}") + + # For webhook processing, use existing data (fast path) + # Manual scans will validate below + # Still get aired date for NFO + aired = None + if series_metadata and 'episodes' in series_metadata: + episode_data = series_metadata['episodes'].get((season_num, episode_num)) + if episode_data: + aired = episode_data.get('airDate') + + return aired, str(existing_date), "database:existing" + + except Exception as e: + _log("DEBUG", f"Database check failed for S{season_num:02d}E{episode_num:02d}: {e}") + + _log("INFO", f"No existing date in database for S{season_num:02d}E{episode_num:02d}, checking Sonarr import history") + # Get aired date and episode ID from Sonarr aired = None episode_id = None @@ -963,8 +992,8 @@ class TVProcessor: episode_id = episode_data.get('id') _log("DEBUG", f"Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}") - # STEP 1: Check Sonarr import history FIRST (this is the key check) - _log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d} to detect import vs rename events") + # STEP 2: Check Sonarr import history (authoritative source) + _log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d}") if episode_id and hasattr(self, 'sonarr'): try: @@ -973,9 +1002,8 @@ class TVProcessor: _log("DEBUG", f"Import history result: {import_history}") if import_history: - # Found actual import event - use this date - _log("INFO", f"Found real import event for S{season_num:02d}E{episode_num:02d}: {import_history}") - _log("INFO", f"Using first import date (not webhook): {import_history}") + # Found actual import event from Sonarr + _log("INFO", f"Found Sonarr import event for S{season_num:02d}E{episode_num:02d}: {import_history}") return aired, import_history, "sonarr:import_history" else: # No import events found - this means only renames/moves exist in history From def8d293b968c58f7963aaa5e096458b7ab2a162 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 13:11:26 -0400 Subject: [PATCH 12/56] fix: add batch delay --- .env.example | 5 +++++ config/settings.py | 1 + webhooks/webhook_batcher.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7ef2928..1422453 100644 --- a/.env.example +++ b/.env.example @@ -138,6 +138,11 @@ BATCH_DELAY=5.0 # Maximum concurrent series processing MAX_CONCURRENT_SERIES=3 +# Sequential processing delay for bulk downloads (seconds) +# When multiple episodes are downloaded at once, process them one by one with this delay +# Set to 0 to disable sequential processing (parallel mode) +SEQUENTIAL_DELAY=20.0 + # API timeout in seconds TIMEOUT_SECONDS=45 diff --git a/config/settings.py b/config/settings.py index c9b3505..5ef8290 100644 --- a/config/settings.py +++ b/config/settings.py @@ -61,6 +61,7 @@ class NFOGuardConfig: # Batching and performance self.batch_delay = self._get_float_env("BATCH_DELAY", 5.0, 0.1, 300.0) self.max_concurrent = self._get_int_env("MAX_CONCURRENT_SERIES", 3, 1, 10) + self.sequential_delay = self._get_float_env("SEQUENTIAL_DELAY", 20.0, 0.0, 60.0) # Delay between sequential episodes (default 20s) # Database self.db_type = os.environ.get("DB_TYPE", "sqlite").lower() diff --git a/webhooks/webhook_batcher.py b/webhooks/webhook_batcher.py index ec0c971..d7b97d5 100644 --- a/webhooks/webhook_batcher.py +++ b/webhooks/webhook_batcher.py @@ -3,6 +3,7 @@ Webhook Batching System for NFOGuard Handles batching and processing of webhook events to avoid processing storms """ import threading +import time from pathlib import Path from typing import Dict, Set from concurrent.futures import ThreadPoolExecutor @@ -144,7 +145,13 @@ class WebhookBatcher: if processing_mode == 'targeted' and episodes_data: _log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes") - self.tv_processor.process_webhook_episodes(path_obj, episodes_data) + + # Handle sequential processing for bulk downloads + if len(episodes_data) > 1 and config.sequential_delay > 0: + _log("INFO", f"Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay") + self._process_episodes_sequentially(path_obj, episodes_data) + else: + self.tv_processor.process_webhook_episodes(path_obj, episodes_data) else: _log("INFO", f"Using series processing mode (fallback or configured)") self.tv_processor.process_series(path_obj) @@ -164,6 +171,29 @@ class WebhookBatcher: with self.lock: self.processing.discard(key) + def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list): + """Process episodes one by one with delays to avoid API spam""" + total_episodes = len(episodes_data) + for i, episode in enumerate(episodes_data, 1): + try: + season = episode.get('seasonNumber', '?') + episode_num = episode.get('episodeNumber', '?') + _log("INFO", f"Processing episode {i}/{total_episodes}: S{season:02d}E{episode_num:02d}") + + # Process single episode + self.tv_processor.process_webhook_episodes(path_obj, [episode]) + + # Add delay between episodes (except for the last one) + if i < total_episodes and config.sequential_delay > 0: + _log("INFO", f"Waiting {config.sequential_delay}s before next episode...") + time.sleep(config.sequential_delay) + + except Exception as e: + _log("ERROR", f"Error processing episode {i}/{total_episodes}: {e}") + # Continue with next episode even if one fails + + _log("INFO", f"Completed sequential processing of {total_episodes} episodes") + def get_status(self) -> Dict: """Get batch queue status""" with self.lock: From ff24e0c4310876db87e79f9b9388dce60abb9a54 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 14:47:28 -0400 Subject: [PATCH 13/56] web: unable to delete in webinterface --- nfoguard-web/api/web_routes.py | 537 +++++++++++++----------------- nfoguard-web/core/web_database.py | 147 ++++++++ nfoguard-web/static/js/app.js | 35 +- 3 files changed, 404 insertions(+), 315 deletions(-) diff --git a/nfoguard-web/api/web_routes.py b/nfoguard-web/api/web_routes.py index 6e197a5..3026213 100644 --- a/nfoguard-web/api/web_routes.py +++ b/nfoguard-web/api/web_routes.py @@ -600,56 +600,26 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi has_video_file=episode_data.get('has_video_file', False) ) - # Create/update NFO file with new data - nfo_manager = dependencies["nfo_manager"] - config = dependencies["config"] - - if config.manage_nfo: - try: - # Find the series directory based on IMDb ID - series_path = None - for tv_path in config.tv_paths: - for series_dir in Path(tv_path).iterdir(): - if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower(): - series_path = series_dir - break - if series_path: - break - - if series_path: - season_dir = series_path / config.tv_season_dir_format.format(season=season) - if season_dir.exists(): - nfo_manager.create_episode_nfo( - season_dir=season_dir, - season_num=season, - episode_num=episode, - aired=episode_data.get('aired'), - dateadded=dateadded, - source=source, - lock_metadata=config.lock_metadata - ) - print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}") - else: - print(f"⚠️ Season directory not found: {season_dir}") - else: - print(f"⚠️ Series directory not found for {imdb_id}") - - except Exception as e: - print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}") + # NFO file management is handled by the core container + # Web interface only updates database entries # Add to processing history - db.add_processing_history( - imdb_id=imdb_id, - media_type="episode", - event_type="manual_date_update", - details={ - "season": season, - "episode": episode, - "old_source": episode_data.get('source'), - "new_source": source, - "dateadded": dateadded - } - ) + try: + db.add_processing_history( + imdb_id=imdb_id, + media_type="episode", + event_type="manual_date_update", + details={ + "season": season, + "episode": episode, + "old_source": episode_data.get('source'), + "new_source": source, + "dateadded": dateadded + } + ) + except Exception as e: + print(f"⚠️ Failed to add processing history: {e}") + # Don't fail the entire update for history logging issues return {"status": "success", "message": f"Updated episode {imdb_id} S{season:02d}E{episode:02d}"} @@ -882,7 +852,7 @@ async def get_movie_date_options(dependencies: dict, imdb_id: str): async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int, episode: int): - """Get available date options for an episode""" + """Get available date options for an episode (simplified for web interface)""" print(f"🔍 DEBUG: get_episode_date_options called with imdb_id={imdb_id}, season={season}, episode={episode}") db = dependencies["db"] @@ -936,267 +906,7 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int "description": f"Use original air date: {episode_data['aired']}" }) - # Option 3: Active lookup from external sources - try: - # Get TV processor and clients from dependencies - tv_processor = dependencies.get("tv_processor") - print(f"🔍 DEBUG: tv_processor available: {tv_processor is not None}") - if tv_processor: - print(f"🔍 DEBUG: tv_processor has external_clients: {hasattr(tv_processor, 'external_clients')}") - print(f"🔍 DEBUG: tv_processor has sonarr: {hasattr(tv_processor, 'sonarr')}") - - if tv_processor and hasattr(tv_processor, 'external_clients'): - external_clients = tv_processor.external_clients - print(f"🔍 DEBUG: external_clients available: {external_clients is not None}") - if external_clients: - print(f"🔍 DEBUG: TMDB enabled: {external_clients.tmdb.enabled if hasattr(external_clients, 'tmdb') else 'No TMDB client'}") - - # Check Sonarr for import dates - if tv_processor.sonarr and tv_processor.sonarr.enabled: - try: - print(f"🔍 DEBUG: Attempting Sonarr lookup for {imdb_id}") - # Look up the series and episode in Sonarr - series_data = tv_processor.sonarr.series_by_imdb(imdb_id) - - # If IMDb lookup fails, try direct series lookup as fallback - if not series_data: - print(f"🔍 DEBUG: IMDb lookup failed, trying direct series lookup") - try: - # Let's also debug what series are available - all_series = tv_processor.sonarr.get_all_series() - print(f"🔍 DEBUG: Found {len(all_series)} total series in Sonarr") - - # Look for Lincoln Lawyer specifically - lincoln_series = [s for s in all_series if 'lincoln' in s.get('title', '').lower()] - print(f"🔍 DEBUG: Lincoln Lawyer series found: {len(lincoln_series)}") - for ls in lincoln_series: - print(f" - Title: '{ls.get('title')}', IMDb: '{ls.get('imdbId')}', ID: {ls.get('id')}") - - # Try direct lookup first - series_data = tv_processor.sonarr.series_by_imdb_direct(imdb_id) - - # If still no match but we found Lincoln Lawyer series, try fuzzy matching - if not series_data and lincoln_series: - target_imdb_num = imdb_id.replace('tt', '').lower() - print(f"🔍 DEBUG: Trying fuzzy match for IMDb number: {target_imdb_num}") - - for ls in lincoln_series: - ls_imdb = ls.get('imdbId', '') - ls_imdb_num = ls_imdb.replace('tt', '').lower() - print(f" - Comparing {target_imdb_num} vs {ls_imdb_num}") - - # Check if IMDb numbers are close (within 10 digits) - if ls_imdb_num and target_imdb_num: - try: - target_num = int(target_imdb_num) - ls_num = int(ls_imdb_num) - diff = abs(target_num - ls_num) - print(f" - Numeric difference: {diff}") - - if diff <= 10: # Allow small IMDb ID differences - print(f"✅ Found close IMDb match: {ls_imdb} vs {imdb_id} (diff: {diff})") - series_data = ls - break - except ValueError: - continue - except Exception as e: - print(f"⚠️ Direct series lookup also failed: {e}") - import traceback - print(f" Traceback: {traceback.format_exc()}") - - print(f"🔍 DEBUG: Series data found: {series_data is not None}") - if series_data: - series_id = series_data.get('id') - series_title = series_data.get('title', 'Unknown') - print(f"🔍 DEBUG: Found series '{series_title}' with ID {series_id}") - - if series_id: - # Get episodes for the series - print(f"🔍 DEBUG: Getting episodes for series {series_id}") - episodes = tv_processor.sonarr.episodes_for_series(series_id) - print(f"🔍 DEBUG: Found {len(episodes)} episodes") - - for ep in episodes: - ep_season = ep.get('seasonNumber') - ep_episode = ep.get('episodeNumber') - # Convert to int for proper comparison (handle both string and int from Sonarr) - try: - ep_season = int(ep_season) if ep_season is not None else None - ep_episode = int(ep_episode) if ep_episode is not None else None - except (ValueError, TypeError): - continue # Skip episodes with invalid season/episode numbers - - if ep_season == season and ep_episode == episode: - episode_id = ep.get('id') - ep_title = ep.get('title', 'Unknown') - ep_air_date = ep.get('airDate') # Get air date from Sonarr - print(f"🔍 DEBUG: Found target episode '{ep_title}' with ID {episode_id}, airDate: {ep_air_date}") - - if episode_id: - # Get import history for this specific episode - print(f"🔍 DEBUG: Getting import history for episode {episode_id}") - import_date = tv_processor.sonarr.get_episode_import_history(episode_id) - print(f"🔍 DEBUG: Import date found: {import_date}") - - if import_date: - # Check if this is different from current date - current_dateadded = episode_data.get('dateadded') - current_date_str = current_dateadded.strftime('%Y-%m-%d') if current_dateadded else '' - if not current_dateadded or not current_date_str.startswith(import_date[:10]): - options.append({ - "type": "sonarr_import", - "label": "Sonarr Import Date", - "date": import_date, - "source": "sonarr:import_history", - "description": f"Import date from Sonarr: {import_date[:10]}" - }) - print(f"✅ Added Sonarr import option: {import_date[:10]}") - - # If no import date but we have air date from Sonarr, add as air date option - if not import_date and ep_air_date: - current_aired = episode_data.get('aired', '') - current_dateadded = episode_data.get('dateadded', '') - - # Add air date option if different from current or missing - if not current_aired or current_aired != ep_air_date: - options.append({ - "type": "sonarr_air", - "label": "Sonarr Air Date", - "date": f"{ep_air_date}T20:00:00", - "source": "sonarr:airdate", - "description": f"Air date from Sonarr: {ep_air_date}" - }) - print(f"✅ Added Sonarr air date option: {ep_air_date}") - - # If no dateadded, suggest using air date as import date fallback - if not current_dateadded: - options.append({ - "type": "sonarr_air_fallback", - "label": "Use Air Date as Import Date", - "date": f"{ep_air_date}T20:00:00", - "source": "sonarr:aired_fallback", - "description": f"Use Sonarr air date as import date: {ep_air_date}" - }) - print(f"✅ Added Sonarr air date fallback option: {ep_air_date}") - - break - else: - print(f"❌ No series found in Sonarr for {imdb_id}") - except Exception as e: - print(f"⚠️ Failed to get Sonarr import date for {imdb_id} S{season:02d}E{episode:02d}: {e}") - import traceback - print(f" Traceback: {traceback.format_exc()}") - - # Check TMDB for episode air dates - if external_clients.tmdb.enabled: - try: - print(f"🔍 DEBUG: Attempting TMDB lookup for {imdb_id}") - # Get TMDB TV series ID from IMDb ID using find endpoint - tv_find_result = external_clients.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"}) - print(f"🔍 DEBUG: TMDB find result: {tv_find_result is not None}") - print(f"🔍 DEBUG: TMDB raw response: {tv_find_result}") - - # Check both tv_results and tv_episode_results - tmdb_id = None - tv_title = "Unknown" - - if tv_find_result and tv_find_result.get("tv_results"): - tv_results = tv_find_result.get("tv_results", []) - print(f"🔍 DEBUG: Found {len(tv_results)} TV results") - - if tv_results: - tv_show = tv_results[0] - tmdb_id = tv_show.get("id") - tv_title = tv_show.get("name", "Unknown") - print(f"🔍 DEBUG: Found TMDB series '{tv_title}' with ID {tmdb_id}") - - # Fallback: Check tv_episode_results for show_id - elif tv_find_result and tv_find_result.get("tv_episode_results"): - episode_results = tv_find_result.get("tv_episode_results", []) - print(f"🔍 DEBUG: Found {len(episode_results)} TV episode results") - - if episode_results: - tmdb_episode_data = episode_results[0] - tmdb_id = tmdb_episode_data.get("show_id") - episode_name = tmdb_episode_data.get("name", "Unknown") - print(f"🔍 DEBUG: Found TMDB series via episode '{episode_name}' with show_id {tmdb_id}") - - if tmdb_id: - print(f"🔍 DEBUG: Using TMDB ID {tmdb_id} for series lookup") - - # Get episode air date from TMDB - print(f"🔍 DEBUG: Getting TMDB season {season} episodes for series {tmdb_id}") - episodes = external_clients.tmdb.get_tv_season_episodes(tmdb_id, season) - print(f"🔍 DEBUG: TMDB episodes found: {episodes}") - - if episode in episodes: - air_date = episodes[episode] - print(f"🔍 DEBUG: TMDB air date for S{season:02d}E{episode:02d}: {air_date}") - - if air_date: - # Check if this is different from current aired date - current_aired = episode_data.get('aired', '') - if not current_aired or current_aired != air_date: - options.append({ - "type": "tmdb_air", - "label": "TMDB Air Date", - "date": f"{air_date}T20:00:00", # Default to 8 PM - "source": "tmdb:airdate", - "description": f"Air date from TMDB: {air_date}" - }) - print(f"✅ Added TMDB air date option: {air_date}") - - # If no aired date in database, also add this as "Use Air Date" option - if not current_aired: - options.insert(1, { # Insert after current option - "type": "airdate_tmdb", - "label": "Use Air Date (TMDB)", - "date": f"{air_date}T20:00:00", - "source": "airdate", - "description": f"Use air date from TMDB: {air_date}" - }) - print(f"✅ Added 'Use Air Date' option from TMDB: {air_date}") - else: - print(f"❌ Episode {episode} not found in TMDB season {season} data") - else: - print(f"❌ No TV series ID found in TMDB for {imdb_id}") - except Exception as e: - print(f"⚠️ Failed to get TMDB air date for {imdb_id} S{season:02d}E{episode:02d}: {e}") - import traceback - print(f" Traceback: {traceback.format_exc()}") - - # Check external clients for episode air dates (TVDB, OMDb) - if hasattr(external_clients, 'get_episode_air_date'): - try: - air_date = external_clients.get_episode_air_date(imdb_id, season, episode) - if air_date: - # Check if this is different from current aired date - current_aired = episode_data.get('aired', '') - if not current_aired or current_aired != air_date: - options.append({ - "type": "external_air", - "label": "External Air Date", - "date": f"{air_date}T20:00:00", # Default to 8 PM - "source": "external:airdate", - "description": f"Air date from external sources: {air_date}" - }) - - # If no aired date in database and not already added from TMDB, add this as "Use Air Date" option - if not current_aired and not any(opt.get('type') == 'airdate_tmdb' for opt in options): - options.insert(1, { # Insert after current option - "type": "airdate_external", - "label": "Use Air Date (External)", - "date": f"{air_date}T20:00:00", - "source": "airdate", - "description": f"Use air date from external sources: {air_date}" - }) - except Exception as e: - print(f"⚠️ Failed to get external air date for {imdb_id} S{season:02d}E{episode:02d}: {e}") - - except Exception as e: - print(f"⚠️ External source lookup failed for {imdb_id} S{season:02d}E{episode:02d}: {e}") - - # Option 4: Manual entry + # Option 3: Manual entry options.append({ "type": "manual", "label": "Manual Entry", @@ -1216,4 +926,209 @@ async def get_episode_date_options(dependencies: dict, imdb_id: str, season: int "episode": episode, "current_data": episode_data, "options": options - } \ No newline at end of file + } + + +async def delete_episode(dependencies: dict, imdb_id: str, season: int, episode: int): + """Delete an episode from the database""" + db = dependencies["db"] + + # Check if episode exists + episode_data = db.get_episode_date(imdb_id, season, episode) + if not episode_data: + raise HTTPException(status_code=404, detail="Episode not found") + + # Delete from database + try: + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", + (imdb_id, season, episode) + ) + conn.commit() + + # Add to processing history + try: + db.add_processing_history( + imdb_id=imdb_id, + media_type="episode", + event_type="manual_deletion", + details={ + "season": season, + "episode": episode, + "deleted_source": episode_data.get('source'), + "deleted_dateadded": episode_data.get('dateadded') + } + ) + except Exception as e: + print(f"⚠️ Failed to add processing history: {e}") + + return {"success": True, "status": "success", "message": f"Deleted episode {imdb_id} S{season:02d}E{episode:02d}"} + + except Exception as e: + print(f"❌ Error deleting episode: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}") + + +async def delete_movie(dependencies: dict, imdb_id: str): + """Delete a movie from the database""" + db = dependencies["db"] + + # Check if movie exists + movie_data = db.get_movie_dates(imdb_id) + if not movie_data: + raise HTTPException(status_code=404, detail="Movie not found") + + # Delete from database + try: + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (imdb_id,)) + conn.commit() + + # Add to processing history + try: + db.add_processing_history( + imdb_id=imdb_id, + media_type="movie", + event_type="manual_deletion", + details={ + "deleted_source": movie_data.get('source'), + "deleted_dateadded": movie_data.get('dateadded'), + "deleted_path": movie_data.get('path') + } + ) + except Exception as e: + print(f"⚠️ Failed to add processing history: {e}") + + return {"success": True, "status": "success", "message": f"Deleted movie {imdb_id}"} + + except Exception as e: + print(f"❌ Error deleting movie: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}") + + +def register_web_routes(app, dependencies): + """Register all web API routes with FastAPI app""" + from fastapi import Request, Response + + # Dashboard and stats endpoints + @app.get("/api/dashboard") + async def api_dashboard(): + return await get_dashboard_stats(dependencies) + + @app.get("/api/dashboard/stats") + async def api_dashboard_stats(): + return await get_dashboard_stats(dependencies) + + # Movies endpoints + @app.get("/api/movies") + async def api_movies_list(skip: int = 0, limit: int = 100, has_date: bool = None, + source_filter: str = None, search: str = None, imdb_search: str = None): + return await get_movies_list(dependencies, skip, limit, has_date, source_filter, search, imdb_search) + + @app.post("/api/movies/{imdb_id}/update-date") + async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"): + return await update_movie_date(dependencies, imdb_id, dateadded, source) + + @app.put("/api/movies/{imdb_id}") + async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"): + return await update_movie_date(dependencies, imdb_id, dateadded, source) + + @app.get("/api/movies/{imdb_id}/date-options") + async def api_movie_date_options(imdb_id: str): + return await get_movie_date_options(dependencies, imdb_id) + + # TV series endpoints + @app.get("/api/series") + async def api_series_list(skip: int = 0, limit: int = 50, search: str = None, + imdb_search: str = None, date_filter: str = None, source_filter: str = None): + return await get_tv_series_list(dependencies, skip, limit, search, imdb_search, date_filter, source_filter) + + @app.get("/api/series/{imdb_id}/episodes") + async def api_series_episodes(imdb_id: str): + return await get_series_episodes(dependencies, imdb_id) + + @app.get("/api/series/sources") + async def api_series_sources(): + return await get_series_sources(dependencies) + + @app.get("/api/series/debug/date-distribution") + async def api_debug_series_date_distribution(): + return await debug_series_date_distribution(dependencies) + + # Episode endpoints - WORKING VERSIONS + @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date") + async def api_update_episode_date(imdb_id: str, season: int, episode: int, + dateadded: str = None, source: str = "manual"): + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) + + @app.put("/api/episodes/{imdb_id}/{season}/{episode}") + async def api_update_episode(imdb_id: str, season: int, episode: int, + dateadded: str = None, source: str = "manual"): + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) + + @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") + async def api_episode_date_options(imdb_id: str, season: int, episode: int): + return await get_episode_date_options(dependencies, imdb_id, season, episode) + + @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") + async def api_delete_episode(imdb_id: str, season: int, episode: int): + return await delete_episode(dependencies, imdb_id, season, episode) + + # Movie deletion endpoint + @app.delete("/api/movies/{imdb_id}") + async def api_delete_movie(imdb_id: str): + return await delete_movie(dependencies, imdb_id) + + # Bulk operations + @app.post("/api/bulk/update-source") + async def api_bulk_update_source(media_type: str, old_source: str, new_source: str): + return await bulk_update_source(dependencies, media_type, old_source, new_source) + + # Reports + @app.get("/api/reports/missing-dates") + async def api_missing_dates_report(): + return await get_missing_dates_report(dependencies) + + # Authentication endpoints (for web interface compatibility) + @app.get("/api/auth/status") + async def api_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/auth/logout") + async def api_auth_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"} + + # Health endpoint + @app.get("/health") + async def health_check(): + """Health check endpoint for container monitoring""" + return {"status": "healthy", "service": "nfoguard-web"} + + print("✅ Web routes registered successfully") \ No newline at end of file diff --git a/nfoguard-web/core/web_database.py b/nfoguard-web/core/web_database.py index 5c95e40..68f2c45 100644 --- a/nfoguard-web/core/web_database.py +++ b/nfoguard-web/core/web_database.py @@ -225,6 +225,153 @@ class WebDatabase: """ return self.execute_query(query) + # Episode-specific methods for web interface + def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]: + """Get episode data including dates""" + query = """ + SELECT imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated + FROM episodes + WHERE imdb_id = %s AND season = %s AND episode = %s + """ + return self.execute_single(query, (imdb_id, season, episode)) + + def upsert_episode_date(self, imdb_id: str, season: int, episode: int, + aired: Optional[str], dateadded: Optional[str], + source: str, has_video_file: bool = False) -> None: + """Update or insert episode date information""" + # First check if episode exists + existing = self.get_episode_date(imdb_id, season, episode) + + # Temporarily disable autocommit for the transaction + original_autocommit = self.connection.autocommit + self.connection.autocommit = False + + try: + with self.connection.cursor() as cursor: + if existing: + # Update existing episode + query = """ + UPDATE episodes + SET aired = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW() + WHERE imdb_id = %s AND season = %s AND episode = %s + """ + cursor.execute(query, (aired, dateadded, source, has_video_file, imdb_id, season, episode)) + else: + # Insert new episode + query = """ + INSERT INTO episodes (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated) + VALUES (%s, %s, %s, %s, %s, %s, %s, NOW()) + """ + cursor.execute(query, (imdb_id, season, episode, aired, dateadded, source, has_video_file)) + + self.connection.commit() + except Exception as e: + self.connection.rollback() + logger.error(f"Failed to upsert episode date: {e}") + raise + finally: + # Restore original autocommit setting + self.connection.autocommit = original_autocommit + + def get_movie_dates(self, imdb_id: str) -> Optional[Dict]: + """Get movie data including dates""" + query = """ + SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated + FROM movies + WHERE imdb_id = %s + """ + return self.execute_single(query, (imdb_id,)) + + def upsert_movie_dates(self, imdb_id: str, released: Optional[str], + dateadded: Optional[str], source: str, + has_video_file: bool = False, path: str = "") -> None: + """Update or insert movie date information""" + # First check if movie exists + existing = self.get_movie_dates(imdb_id) + + # Temporarily disable autocommit for the transaction + original_autocommit = self.connection.autocommit + self.connection.autocommit = False + + try: + with self.connection.cursor() as cursor: + if existing: + # Update existing movie + query = """ + UPDATE movies + SET released = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW() + WHERE imdb_id = %s + """ + cursor.execute(query, (released, dateadded, source, has_video_file, imdb_id)) + else: + # Insert new movie + query = """ + INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated) + VALUES (%s, %s, %s, %s, %s, %s, NOW()) + """ + cursor.execute(query, (imdb_id, path, released, dateadded, source, has_video_file)) + + self.connection.commit() + except Exception as e: + self.connection.rollback() + logger.error(f"Failed to upsert movie dates: {e}") + raise + finally: + # Restore original autocommit setting + self.connection.autocommit = original_autocommit + + def get_connection(self): + """Get database connection for advanced operations""" + return self.connection + + def _get_first_value(self, row): + """Extract first value from a database row (compatibility method)""" + if row is None: + return None + if isinstance(row, dict): + return list(row.values())[0] if row else None + return row[0] if row else None + + def get_stats(self) -> Dict[str, Any]: + """Get basic database statistics (compatibility method)""" + return self.get_dashboard_stats() + + def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Dict) -> None: + """Add processing history entry (simplified for web interface)""" + # Temporarily disable autocommit for the transaction + original_autocommit = self.connection.autocommit + self.connection.autocommit = False + + try: + with self.connection.cursor() as cursor: + # Check if processing_history table exists + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'processing_history' + ) + """) + table_exists = cursor.fetchone()[0] + + if table_exists: + query = """ + INSERT INTO processing_history (imdb_id, media_type, event_type, details, processed_at) + VALUES (%s, %s, %s, %s, NOW()) + """ + import json + cursor.execute(query, (imdb_id, media_type, event_type, json.dumps(details))) + self.connection.commit() + else: + # Table doesn't exist, skip logging + logger.debug("Processing history table not found, skipping log entry") + except Exception as e: + self.connection.rollback() + logger.error(f"Failed to add processing history: {e}") + # Don't raise, this is non-critical + finally: + # Restore original autocommit setting + self.connection.autocommit = original_autocommit + def close(self): """Close database connection""" if self.connection: diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 79ee839..21867de 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -676,6 +676,13 @@ async function smartFixMovie(imdbId) { } async function smartFixEpisode(imdbId, season, episode) { + // Validate parameters + if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { + console.error('smartFixEpisode: Invalid parameters:', {imdbId, season, episode}); + showToast('Invalid episode parameters', 'error'); + return; + } + try { const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); showSmartFixModal('episode', options); @@ -703,7 +710,10 @@ function showSmartFixModal(mediaType, options) { if (mediaType === 'movie') { title.textContent = `Fix Date for Movie: ${options.imdb_id}`; } else { - title.textContent = `Fix Date for Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`; + // Add validation for episode data + const season = options.season || 0; + const episode = options.episode || 0; + title.textContent = `Fix Date for Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; } // Build options HTML @@ -878,7 +888,10 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour if (mediaType === 'movie') { title.textContent = `Edit Movie: ${options.imdb_id}`; } else { - title.textContent = `Edit Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`; + // Add validation for episode data + const season = options.season || 0; + const episode = options.episode || 0; + title.textContent = `Edit Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; } // Build enhanced edit form with date options @@ -1062,6 +1075,13 @@ async function handleEnhancedEditSubmit(event) { } async function editEpisode(imdbId, season, episode, dateadded, source) { + // Validate parameters + if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { + console.error('editEpisode: Invalid parameters:', {imdbId, season, episode}); + showToast('Invalid episode parameters', 'error'); + return; + } + try { // Load episode options to populate available dates const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); @@ -1244,6 +1264,13 @@ Analysis: // Episode deletion functionality async function deleteEpisode(imdbId, season, episode) { + // Validate parameters + if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { + console.error('deleteEpisode: Invalid parameters:', {imdbId, season, episode}); + showToast('Invalid episode parameters', 'error'); + return; + } + const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; // Confirmation dialog @@ -1252,7 +1279,7 @@ async function deleteEpisode(imdbId, season, episode) { } try { - const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, { + const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' @@ -1295,7 +1322,7 @@ async function deleteMovie(imdbId) { } try { - const response = await fetch(`/database/movie/${imdbId}`, { + const response = await fetch(`/api/movies/${imdbId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' From 2f4d4638efad59ba5d20f1cc481d0a0e5bd64e14 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 14:52:14 -0400 Subject: [PATCH 14/56] web: not deleteing --- api/web_routes.py | 8 ++++---- nfoguard-web/api/web_routes.py | 8 ++++---- static/js/app.js | 35 ++++++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 81d3732..ce7bf9b 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -93,11 +93,11 @@ async def get_movies_list(dependencies: dict, params.append(source_filter) if search: - where_conditions.append("(imdb_id LIKE %s OR path LIKE %s)") + where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s)") params.extend([f"%{search}%", f"%{search}%"]) if imdb_search: - where_conditions.append("imdb_id LIKE %s") + where_conditions.append("imdb_id ILIKE %s") params.append(f"%{imdb_search}%") where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" @@ -162,11 +162,11 @@ async def get_tv_series_list(dependencies: dict, having_conditions = [] if search: - where_conditions.append("(s.imdb_id LIKE %s OR s.path LIKE %s)") + where_conditions.append("(s.imdb_id ILIKE %s OR s.path ILIKE %s)") params.extend([f"%{search}%", f"%{search}%"]) if imdb_search: - where_conditions.append("s.imdb_id LIKE %s") + where_conditions.append("s.imdb_id ILIKE %s") params.append(f"%{imdb_search}%") if source_filter: diff --git a/nfoguard-web/api/web_routes.py b/nfoguard-web/api/web_routes.py index 3026213..8848839 100644 --- a/nfoguard-web/api/web_routes.py +++ b/nfoguard-web/api/web_routes.py @@ -96,11 +96,11 @@ async def get_movies_list(dependencies: dict, params.append(source_filter) if search: - where_conditions.append("(imdb_id LIKE %s OR path LIKE %s)") + where_conditions.append("(imdb_id ILIKE %s OR path ILIKE %s)") params.extend([f"%{search}%", f"%{search}%"]) if imdb_search: - where_conditions.append("imdb_id LIKE %s") + where_conditions.append("imdb_id ILIKE %s") params.append(f"%{imdb_search}%") where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" @@ -165,11 +165,11 @@ async def get_tv_series_list(dependencies: dict, having_conditions = [] if search: - where_conditions.append("(s.imdb_id LIKE %s OR s.path LIKE %s)") + where_conditions.append("(s.imdb_id ILIKE %s OR s.path ILIKE %s)") params.extend([f"%{search}%", f"%{search}%"]) if imdb_search: - where_conditions.append("s.imdb_id LIKE %s") + where_conditions.append("s.imdb_id ILIKE %s") params.append(f"%{imdb_search}%") if source_filter: diff --git a/static/js/app.js b/static/js/app.js index aa1b7e2..091183e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -667,6 +667,13 @@ async function smartFixMovie(imdbId) { } async function smartFixEpisode(imdbId, season, episode) { + // Validate parameters + if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { + console.error('smartFixEpisode: Invalid parameters:', {imdbId, season, episode}); + showToast('Invalid episode parameters', 'error'); + return; + } + try { const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); showSmartFixModal('episode', options); @@ -694,7 +701,10 @@ function showSmartFixModal(mediaType, options) { if (mediaType === 'movie') { title.textContent = `Fix Date for Movie: ${options.imdb_id}`; } else { - title.textContent = `Fix Date for Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`; + // Add validation for episode data + const season = options.season || 0; + const episode = options.episode || 0; + title.textContent = `Fix Date for Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; } // Build options HTML @@ -910,7 +920,10 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour if (mediaType === 'movie') { title.textContent = `Edit Movie: ${options.imdb_id}`; } else { - title.textContent = `Edit Episode: ${options.imdb_id} S${options.season.toString().padStart(2, '0')}E${options.episode.toString().padStart(2, '0')}`; + // Add validation for episode data + const season = options.season || 0; + const episode = options.episode || 0; + title.textContent = `Edit Episode: ${options.imdb_id} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; } // Build enhanced edit form with date options @@ -1094,6 +1107,13 @@ async function handleEnhancedEditSubmit(event) { } async function editEpisode(imdbId, season, episode, dateadded, source) { + // Validate parameters + if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { + console.error('editEpisode: Invalid parameters:', {imdbId, season, episode}); + showToast('Invalid episode parameters', 'error'); + return; + } + try { // Load episode options to populate available dates const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); @@ -1276,6 +1296,13 @@ Analysis: // Episode deletion functionality async function deleteEpisode(imdbId, season, episode) { + // Validate parameters + if (!imdbId || season === undefined || season === null || episode === undefined || episode === null) { + console.error('deleteEpisode: Invalid parameters:', {imdbId, season, episode}); + showToast('Invalid episode parameters', 'error'); + return; + } + const episodeStr = `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`; // Confirmation dialog @@ -1284,7 +1311,7 @@ async function deleteEpisode(imdbId, season, episode) { } try { - const response = await fetch(`/database/episode/${imdbId}/${season}/${episode}`, { + const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' @@ -1327,7 +1354,7 @@ async function deleteMovie(imdbId) { } try { - const response = await fetch(`/database/movie/${imdbId}`, { + const response = await fetch(`/api/movies/${imdbId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' From 0666a4d9a956e388a45a8b65bb3593791cbc1710 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:01:46 -0400 Subject: [PATCH 15/56] fix: web interface not deleting lines --- nfoguard-web/static/index.html | 4 ++-- nfoguard-web/static/js/app.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index bbc8d68..1354015 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -4,7 +4,7 @@ NFOGuard - Database Management - + @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 21867de..c48b8b3 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -545,10 +545,10 @@ function showEpisodesModal(data) { ${episode.source_description || episode.source || 'Unknown'} ${hasVideoBadge} - - @@ -649,7 +649,7 @@ function updateReportTables(data) { ${episode.aired || '-'} ${episode.source_description || episode.source || 'Unknown'} - From b3adf45f7b3f935414dc2eb420b9c373ac73e224 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:06:19 -0400 Subject: [PATCH 16/56] updates: to web delete --- nfoguard-web/api/web_routes.py | 8 ++++---- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/nfoguard-web/api/web_routes.py b/nfoguard-web/api/web_routes.py index 8848839..36eae74 100644 --- a/nfoguard-web/api/web_routes.py +++ b/nfoguard-web/api/web_routes.py @@ -1069,14 +1069,14 @@ def register_web_routes(app, dependencies): dateadded: str = None, source: str = "manual"): return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) - @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") - async def api_episode_date_options(imdb_id: str, season: int, episode: int): - return await get_episode_date_options(dependencies, imdb_id, season, episode) - @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") async def api_delete_episode(imdb_id: str, season: int, episode: int): return await delete_episode(dependencies, imdb_id, season, episode) + @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") + async def api_episode_date_options(imdb_id: str, season: int, episode: int): + return await get_episode_date_options(dependencies, imdb_id, season, episode) + # Movie deletion endpoint @app.delete("/api/movies/{imdb_id}") async def api_delete_movie(imdb_id: str): diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 1354015..2b1112f 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index c48b8b3..7d68e47 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1064,7 +1064,9 @@ async function handleEnhancedEditSubmit(event) { if (mediaType === 'movie') { await updateMovieDate(imdbId, isoDateadded, source); } else { - await updateEpisodeDate(imdbId, options.season, options.episode, isoDateadded, source); + const season = parseInt(document.getElementById('edit-season').value); + const episode = parseInt(document.getElementById('edit-episode').value); + await updateEpisodeDate(imdbId, season, episode, isoDateadded, source); } closeModal(); From 6d64812c94182d93b31eb538afa98fb5f167496c Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:11:25 -0400 Subject: [PATCH 17/56] WEB: INTERFACE AND AUTH FAILS TO DELETE --- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 2b1112f..212eb61 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 7d68e47..c6323a0 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -92,6 +92,7 @@ function initializeEventListeners() { async function apiCall(endpoint, options = {}) { try { const response = await fetch(endpoint, { + credentials: 'include', // Include cookies for authentication headers: { 'Content-Type': 'application/json', ...options.headers @@ -912,7 +913,7 @@ function showEnhancedEditModal(mediaType, options, currentDateadded, currentSour `; // Add available date options - options.options.forEach((option, index) => { + (options.date_options || options.options || []).forEach((option, index) => { const isSelected = option.source === currentSource ? 'checked' : ''; const optionId = `date-option-${index}`; @@ -1086,7 +1087,14 @@ async function editEpisode(imdbId, season, episode, dateadded, source) { try { // Load episode options to populate available dates - const options = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); + const dateOptions = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}/date-options`); + // Create options object with episode metadata + const options = { + imdb_id: imdbId, + season: season, + episode: episode, + date_options: dateOptions.options || [] + }; showEnhancedEditModal('episode', options, dateadded, source); } catch (error) { console.error('Failed to load episode options for edit:', error); From 228053a573a68278c8363cbf754670e4393592de Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:15:50 -0400 Subject: [PATCH 18/56] web: more web debugs --- nfoguard-web/api/auth.py | 4 ++-- nfoguard-web/api/web_routes.py | 12 ++++++++++-- nfoguard-web/static/index.html | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/nfoguard-web/api/auth.py b/nfoguard-web/api/auth.py index 9688f0e..0fd2fd9 100644 --- a/nfoguard-web/api/auth.py +++ b/nfoguard-web/api/auth.py @@ -84,7 +84,6 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware): "/static/", # Static files (CSS, JS) "/api/movies", # Web API endpoints "/api/series", - "/api/episodes", "/api/dashboard" ] @@ -99,7 +98,8 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware): "/manual/", # Manual scan endpoints (API access) "/debug/", # Debug endpoints (API access) "/test/", # Test endpoints (API access) - "/bulk/" # Bulk operation endpoints (API access) + "/bulk/", # Bulk operation endpoints (API access) + "/api/episodes" # Temporary: Make episode endpoints public for testing ] async def dispatch(self, request: Request, call_next): diff --git a/nfoguard-web/api/web_routes.py b/nfoguard-web/api/web_routes.py index 36eae74..aa8bfd6 100644 --- a/nfoguard-web/api/web_routes.py +++ b/nfoguard-web/api/web_routes.py @@ -1069,10 +1069,18 @@ def register_web_routes(app, dependencies): dateadded: str = None, source: str = "manual"): return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) - @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") - async def api_delete_episode(imdb_id: str, season: int, episode: int): + # Register DELETE route explicitly + async def api_delete_episode_handler(imdb_id: str, season: int, episode: int): return await delete_episode(dependencies, imdb_id, season, episode) + app.add_api_route( + "/api/episodes/{imdb_id}/{season}/{episode}", + api_delete_episode_handler, + methods=["DELETE"], + name="delete_episode" + ) + print("✅ DELETE /api/episodes/{imdb_id}/{season}/{episode} route registered") + @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") async def api_episode_date_options(imdb_id: str, season: int, episode: int): return await get_episode_date_options(dependencies, imdb_id, season, episode) diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 212eb61..d759c04 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file From 4713bf5ed1d26043f520af43ee22bfea0ba4a7f1 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:36:03 -0400 Subject: [PATCH 19/56] web: delete debug --- nfoguard-web/api/auth.py | 4 +- nfoguard-web/static/index.html | 2 +- start_web.py | 67 +++++++++++++++------------------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/nfoguard-web/api/auth.py b/nfoguard-web/api/auth.py index 0fd2fd9..9688f0e 100644 --- a/nfoguard-web/api/auth.py +++ b/nfoguard-web/api/auth.py @@ -84,6 +84,7 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware): "/static/", # Static files (CSS, JS) "/api/movies", # Web API endpoints "/api/series", + "/api/episodes", "/api/dashboard" ] @@ -98,8 +99,7 @@ class SimpleAuthMiddleware(BaseHTTPMiddleware): "/manual/", # Manual scan endpoints (API access) "/debug/", # Debug endpoints (API access) "/test/", # Test endpoints (API access) - "/bulk/", # Bulk operation endpoints (API access) - "/api/episodes" # Temporary: Make episode endpoints public for testing + "/bulk/" # Bulk operation endpoints (API access) ] async def dispatch(self, request: Request, call_next): diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index d759c04..fde3fcb 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/start_web.py b/start_web.py index a599e1a..56fe28b 100644 --- a/start_web.py +++ b/start_web.py @@ -11,17 +11,14 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -# Import existing configuration -from config.settings import config +# Add web interface path for imports +sys.path.append(os.path.join(os.path.dirname(__file__), "nfoguard-web")) -# Import existing database and components -from core.database import NFOGuardDatabase - -# Import web routes from existing system +# Import web-specific components +from config.web_settings import web_config as config +from core.web_database import WebDatabase from api.web_routes import register_web_routes - -# Import authentication system -from api.auth import SimpleAuthMiddleware, AuthSession +from api.auth import SimpleAuthMiddleware, create_auth_dependencies def create_web_app() -> FastAPI: @@ -105,50 +102,46 @@ def main(): """Main entry point for NFOGuard Web Interface""" print("🌐 Starting NFOGuard Web Interface...") - # Use existing config system - web_host = os.environ.get("WEB_HOST", "0.0.0.0") - web_port = int(os.environ.get("WEB_PORT", "8081")) + # Use web config system + web_host = config.web_host + web_port = config.web_port print(f"📊 Configuration: Port {web_port}") # Create FastAPI app app = create_web_app() - # Initialize database using existing system + # Initialize web database (read-only optimized) try: - db = NFOGuardDatabase(config) + db = WebDatabase( + db_type=config.db_type, + host=config.db_host, + port=config.db_port, + database=config.db_name, + user=config.db_user, + password=config.db_password + ) print(f"✅ Connected to database: {config.db_host}:{config.db_port}/{config.db_name}") except Exception as e: print(f"❌ Failed to connect to database: {e}") sys.exit(1) - # Setup authentication if enabled - auth_enabled = getattr(config, 'web_auth_enabled', False) - session_manager = None - - if auth_enabled: - session_timeout = getattr(config, 'web_auth_session_timeout', 3600) - session_manager = AuthSession(timeout_seconds=session_timeout) - print(f"🔐 Web authentication enabled (session timeout: {session_timeout}s)") - else: - print("🌐 Web authentication disabled") - - # Create dependencies for dependency injection (simplified for web-only) + # Create dependencies for dependency injection dependencies = { "db": db, - "config": config, - "nfo_manager": None, # Not needed for read-only web interface - "movie_processor": None, # Not needed for read-only web interface - "tv_processor": None, # Not needed for read-only web interface - "auth_enabled": auth_enabled, - "session_manager": session_manager + "config": config } - # Add authentication middleware if enabled (BEFORE routes) - if auth_enabled: - # Pass the session manager to middleware so it uses the same instance - app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager) - print("🔐 Authentication middleware added to web interface") + # Add authentication dependencies if enabled + if config.web_auth_enabled: + auth_deps = create_auth_dependencies(config) + dependencies.update(auth_deps) + + # Add authentication middleware + app.add_middleware(SimpleAuthMiddleware, config=config) + print(f"🔐 Web authentication enabled for user: {config.web_auth_username}") + else: + print("🔓 Web authentication disabled - interface is public") # Setup static files and routes setup_static_files(app) From cb62ddb187cf7b15dfb12a4f3db13d613370099d Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:38:06 -0400 Subject: [PATCH 20/56] web: fixes/debug --- start_web.py | 88 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/start_web.py b/start_web.py index 56fe28b..ccaf946 100644 --- a/start_web.py +++ b/start_web.py @@ -11,14 +11,30 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -# Add web interface path for imports -sys.path.append(os.path.join(os.path.dirname(__file__), "nfoguard-web")) +# Import existing configuration (keep using core config for simplicity) +from config.settings import config -# Import web-specific components -from config.web_settings import web_config as config -from core.web_database import WebDatabase -from api.web_routes import register_web_routes -from api.auth import SimpleAuthMiddleware, create_auth_dependencies +# Import existing database and components +from core.database import NFOGuardDatabase + +# Add web interface path for routes only +web_path = os.path.join(os.path.dirname(__file__), "nfoguard-web") +if web_path not in sys.path: + sys.path.append(web_path) + +# Import web routes from separated system +try: + from api.web_routes import register_web_routes as register_separated_web_routes + use_separated_routes = True + print("✅ Using separated web routes with DELETE /api/episodes/ support") +except ImportError: + # Fallback to old routes if separated routes not available + from api.web_routes import register_web_routes + use_separated_routes = False + print("⚠️ Using legacy web routes - DELETE functionality may be limited") + +# Import authentication system +from api.auth import SimpleAuthMiddleware, AuthSession def create_web_app() -> FastAPI: @@ -102,52 +118,60 @@ def main(): """Main entry point for NFOGuard Web Interface""" print("🌐 Starting NFOGuard Web Interface...") - # Use web config system - web_host = config.web_host - web_port = config.web_port + # Use existing config system + web_host = os.environ.get("WEB_HOST", "0.0.0.0") + web_port = int(os.environ.get("WEB_PORT", "8081")) print(f"📊 Configuration: Port {web_port}") # Create FastAPI app app = create_web_app() - # Initialize web database (read-only optimized) + # Initialize database using existing system try: - db = WebDatabase( - db_type=config.db_type, - host=config.db_host, - port=config.db_port, - database=config.db_name, - user=config.db_user, - password=config.db_password - ) + db = NFOGuardDatabase(config) print(f"✅ Connected to database: {config.db_host}:{config.db_port}/{config.db_name}") except Exception as e: print(f"❌ Failed to connect to database: {e}") sys.exit(1) + # Setup authentication if enabled + auth_enabled = getattr(config, 'web_auth_enabled', False) + session_manager = None + + if auth_enabled: + session_timeout = getattr(config, 'web_auth_session_timeout', 3600) + session_manager = AuthSession(timeout_seconds=session_timeout) + print(f"🔐 Web authentication enabled (session timeout: {session_timeout}s)") + else: + print("🌐 Web authentication disabled") + # Create dependencies for dependency injection dependencies = { "db": db, - "config": config + "config": config, + "nfo_manager": None, # Not needed for read-only web interface + "movie_processor": None, # Not needed for read-only web interface + "tv_processor": None, # Not needed for read-only web interface + "auth_enabled": auth_enabled, + "session_manager": session_manager } - # Add authentication dependencies if enabled - if config.web_auth_enabled: - auth_deps = create_auth_dependencies(config) - dependencies.update(auth_deps) - - # Add authentication middleware - app.add_middleware(SimpleAuthMiddleware, config=config) - print(f"🔐 Web authentication enabled for user: {config.web_auth_username}") - else: - print("🔓 Web authentication disabled - interface is public") + # Add authentication middleware if enabled (BEFORE routes) + if auth_enabled: + app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager) + print("🔐 Authentication middleware added to web interface") # Setup static files and routes setup_static_files(app) - # Register web routes - register_web_routes(app, dependencies) + # Register web routes (prefer separated routes if available) + if use_separated_routes: + register_separated_web_routes(app, dependencies) + print("✅ Registered separated web routes with full /api/episodes/ support") + else: + register_web_routes(app, dependencies) + print("⚠️ Using legacy web routes") print(f"🚀 Starting web server on {web_host}:{web_port}") From 98da1a07f0594182997d60dd91a40ed15e48ef2c Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:43:53 -0400 Subject: [PATCH 21/56] web: debuging --- api/web_routes.py | 38 ++++++++++++++++++++++++++++++++++ nfoguard-web/static/index.html | 2 +- start_web.py | 27 +++++------------------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index ce7bf9b..6a199b5 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1280,6 +1280,44 @@ def register_web_routes(app, dependencies): async def api_episode_date_options(imdb_id: str, season: int, episode: int): return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."} + @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") + async def api_delete_episode(imdb_id: str, season: int, episode: int): + """Delete an episode from the database""" + db = dependencies["db"] + + try: + # Check if episode exists + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT season, episode, dateadded, source FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", + (imdb_id, season, episode) + ) + episode_data = cursor.fetchone() + + if not episode_data: + raise HTTPException(status_code=404, detail="Episode not found") + + # Delete the episode + cursor.execute( + "DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", + (imdb_id, season, episode) + ) + conn.commit() + + return { + "success": True, + "status": "success", + "message": f"Deleted episode {imdb_id} S{season:02d}E{episode:02d}", + "imdb_id": imdb_id, + "season": season, + "episode": episode + } + + except Exception as e: + print(f"❌ Error deleting episode: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}") + # Bulk operations @app.post("/api/bulk/update-source") async def api_bulk_update_source(media_type: str, old_source: str, new_source: str): diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index fde3fcb..bebdb29 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/start_web.py b/start_web.py index ccaf946..98b7b8f 100644 --- a/start_web.py +++ b/start_web.py @@ -17,21 +17,8 @@ from config.settings import config # Import existing database and components from core.database import NFOGuardDatabase -# Add web interface path for routes only -web_path = os.path.join(os.path.dirname(__file__), "nfoguard-web") -if web_path not in sys.path: - sys.path.append(web_path) - -# Import web routes from separated system -try: - from api.web_routes import register_web_routes as register_separated_web_routes - use_separated_routes = True - print("✅ Using separated web routes with DELETE /api/episodes/ support") -except ImportError: - # Fallback to old routes if separated routes not available - from api.web_routes import register_web_routes - use_separated_routes = False - print("⚠️ Using legacy web routes - DELETE functionality may be limited") +# Import web routes from existing system (now includes DELETE route) +from api.web_routes import register_web_routes # Import authentication system from api.auth import SimpleAuthMiddleware, AuthSession @@ -165,13 +152,9 @@ def main(): # Setup static files and routes setup_static_files(app) - # Register web routes (prefer separated routes if available) - if use_separated_routes: - register_separated_web_routes(app, dependencies) - print("✅ Registered separated web routes with full /api/episodes/ support") - else: - register_web_routes(app, dependencies) - print("⚠️ Using legacy web routes") + # Register web routes (now includes DELETE /api/episodes/ route) + register_web_routes(app, dependencies) + print("✅ Registered web routes with DELETE /api/episodes/ support") print(f"🚀 Starting web server on {web_host}:{web_port}") From 059d3bae3f15f019f6b40c7f84b33ccb50919d31 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:47:43 -0400 Subject: [PATCH 22/56] web: debug --- api/web_routes.py | 27 ++++++++------------------- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 16 +++------------- 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 6a199b5..ba5d3cf 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1286,25 +1286,10 @@ def register_web_routes(app, dependencies): db = dependencies["db"] try: - # Check if episode exists - with db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT season, episode, dateadded, source FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", - (imdb_id, season, episode) - ) - episode_data = cursor.fetchone() - - if not episode_data: - raise HTTPException(status_code=404, detail="Episode not found") - - # Delete the episode - cursor.execute( - "DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s", - (imdb_id, season, episode) - ) - conn.commit() - + # Use the existing database method + deleted = db.delete_episode(imdb_id, season, episode) + + if deleted: return { "success": True, "status": "success", @@ -1313,7 +1298,11 @@ def register_web_routes(app, dependencies): "season": season, "episode": episode } + else: + raise HTTPException(status_code=404, detail="Episode not found") + except HTTPException: + raise # Re-raise HTTP exceptions except Exception as e: print(f"❌ Error deleting episode: {e}") raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}") diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index bebdb29..2d871c6 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index c6323a0..1bb327c 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1289,16 +1289,11 @@ async function deleteEpisode(imdbId, season, episode) { } try { - const response = await fetch(`/api/episodes/${imdbId}/${season}/${episode}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json' - } + const result = await apiCall(`/api/episodes/${imdbId}/${season}/${episode}`, { + method: 'DELETE' }); - const result = await response.json(); - - if (response.ok && result.success) { + if (result.success) { showToast(`✅ Episode ${episodeStr} deleted successfully`, 'success'); // Remove the row from the table @@ -1312,11 +1307,6 @@ async function deleteEpisode(imdbId, season, episode) { // Update episode counts in modal header updateEpisodeModalCounts(); - - } else { - const errorMsg = result.message || result.error || 'Unknown error'; - showToast(`❌ Failed to delete episode: ${errorMsg}`, 'error'); - } } catch (error) { console.error('Delete episode failed:', error); From 8c850ab38c3b201de2113533774af6852c6d03ab Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:51:34 -0400 Subject: [PATCH 23/56] web: debug --- api/web_routes.py | 25 +++++++++++++++++++++++++ nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 15 +++------------ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index ba5d3cf..01061e6 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1247,6 +1247,31 @@ def register_web_routes(app, dependencies): async def api_movie_date_options(imdb_id: str): return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."} + @app.delete("/api/movies/{imdb_id}") + async def api_delete_movie(imdb_id: str): + """Delete a movie from the database""" + db = dependencies["db"] + + try: + # Use the existing database method + deleted = db.delete_movie(imdb_id) + + if deleted: + return { + "success": True, + "status": "success", + "message": f"Deleted movie {imdb_id}", + "imdb_id": imdb_id + } + else: + raise HTTPException(status_code=404, detail="Movie not found") + + except HTTPException: + raise # Re-raise HTTP exceptions + except Exception as e: + print(f"❌ Error deleting movie: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete movie: {str(e)}") + # TV series endpoints @app.get("/api/series") async def api_series_list(skip: int = 0, limit: int = 50, search: str = None, diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 2d871c6..957aeea 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 1bb327c..ff0b565 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1322,24 +1322,15 @@ async function deleteMovie(imdbId) { } try { - const response = await fetch(`/api/movies/${imdbId}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json' - } + const result = await apiCall(`/api/movies/${imdbId}`, { + method: 'DELETE' }); - const result = await response.json(); - - if (response.ok && result.success) { + if (result.success) { showToast(`✅ Movie deleted successfully`, 'success'); // Refresh the movies table loadMovies(currentMoviesPage); - - } else { - const errorMsg = result.message || result.error || 'Unknown error'; - showToast(`❌ Failed to delete movie: ${errorMsg}`, 'error'); } } catch (error) { From ef899a05c7afaa4b9b98aa896b227b9db3fe2b2d Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:54:08 -0400 Subject: [PATCH 24/56] web: debugs --- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 957aeea..929d860 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index ff0b565..73ff854 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1307,6 +1307,9 @@ async function deleteEpisode(imdbId, season, episode) { // Update episode counts in modal header updateEpisodeModalCounts(); + } else { + showToast(`❌ Failed to delete episode: ${result.message || 'Unknown error'}`, 'error'); + } } catch (error) { console.error('Delete episode failed:', error); From cf6ee5d6d89d20e37b489412ba56642c340fc306 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sat, 25 Oct 2025 15:58:01 -0400 Subject: [PATCH 25/56] web: debugs --- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 929d860..f434527 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -457,6 +457,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 73ff854..14f72ff 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -539,7 +539,7 @@ function showEpisodesModal(data) { `${dateadded}`; return ` - + S${episode.season.toString().padStart(2, '0')}E${episode.episode.toString().padStart(2, '0')} ${episode.aired || '-'} ${dateCell} @@ -1349,20 +1349,19 @@ function updateEpisodeModalCounts() { const episodesWithDates = Array.from(remainingRows).filter(row => row.getAttribute('data-has-date') === 'true' ).length; + const episodesWithVideo = Array.from(remainingRows).filter(row => + row.getAttribute('data-has-video') === 'true' + ).length; const episodesWithoutDates = totalEpisodes - episodesWithDates; // Update the stats in the modal const statsDiv = document.querySelector('.episode-stats'); if (statsDiv) { - // Keep the existing "With Video" count by finding it - const videoCountDiv = statsDiv.querySelector('div:nth-child(4)'); - const videoCountText = videoCountDiv ? videoCountDiv.innerHTML : '
With Video: -
'; - statsDiv.innerHTML = `
Total Episodes: ${totalEpisodes}
With Dates: ${episodesWithDates}
Missing Dates: ${episodesWithoutDates}
- ${videoCountText} +
With Video: ${episodesWithVideo}
`; } } From f46f1df7cbce2d9691818315f10473fbbcae4a8e Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:04:55 -0400 Subject: [PATCH 26/56] web: fix missing .nfo dates and manual date changes --- api/web_routes.py | 307 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 300 insertions(+), 7 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 01061e6..1e7efb5 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1237,15 +1237,15 @@ def register_web_routes(app, dependencies): @app.post("/api/movies/{imdb_id}/update-date") async def api_update_movie_date(imdb_id: str, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_movie_date(dependencies, imdb_id, dateadded, source) @app.put("/api/movies/{imdb_id}") async def api_update_movie(imdb_id: str, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_movie_date(dependencies, imdb_id, dateadded, source) @app.get("/api/movies/{imdb_id}/date-options") async def api_movie_date_options(imdb_id: str): - return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."} + return await get_movie_date_options(dependencies, imdb_id) @app.delete("/api/movies/{imdb_id}") async def api_delete_movie(imdb_id: str): @@ -1294,16 +1294,16 @@ def register_web_routes(app, dependencies): @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-date") async def api_update_episode_date(imdb_id: str, season: int, episode: int, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) @app.put("/api/episodes/{imdb_id}/{season}/{episode}") async def api_update_episode(imdb_id: str, season: int, episode: int, dateadded: str = None, source: str = "manual"): - return {"error": "Updates not available in web container. Use core container on port 8085."} + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") async def api_episode_date_options(imdb_id: str, season: int, episode: int): - return {"options": [], "message": "Date options not available in web container. Use core container on port 8085."} + return await get_episode_date_options(dependencies, imdb_id, season, episode) @app.delete("/api/episodes/{imdb_id}/{season}/{episode}") async def api_delete_episode(imdb_id: str, season: int, episode: int): @@ -1458,4 +1458,297 @@ def register_web_routes(app, dependencies): except json.JSONDecodeError: return {"scanning": False, "message": "Invalid response from core container"} except Exception as e: - return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"} \ No newline at end of file + return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"} + + # Register database admin routes + await register_database_admin_routes(app, dependencies) + + +# ============================================================================= +# DATABASE ADMIN INTERFACE +# ============================================================================= + +async def execute_database_query(dependencies: dict, query: str, limit: int = 100): + """Execute a database query and return results""" + db = dependencies["db"] + + try: + with db.get_connection() as conn: + cursor = conn.cursor() + + # Add LIMIT if not already present in query + if "LIMIT" not in query.upper() and not query.strip().endswith(";"): + query += f" LIMIT {limit}" + elif query.strip().endswith(";"): + query = query.strip()[:-1] + f" LIMIT {limit};" + + cursor.execute(query) + + if query.strip().upper().startswith("SELECT"): + results = cursor.fetchall() + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + return { + "success": True, + "query": query, + "columns": columns, + "rows": [dict(zip(columns, row)) for row in results], + "row_count": len(results) + } + else: + conn.commit() + return { + "success": True, + "query": query, + "message": f"Query executed successfully. Rows affected: {cursor.rowcount}", + "rows_affected": cursor.rowcount + } + + except Exception as e: + return { + "success": False, + "query": query, + "error": str(e), + "message": f"Database query failed: {str(e)}" + } + + +async def get_episodes_missing_nfo_dateadded(dependencies: dict): + """Find episodes that have dateadded in database but missing from NFO files""" + db = dependencies["db"] + config = dependencies["config"] + + try: + # Get all episodes with dateadded in database + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, e.has_video_file, + s.path as series_path + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.dateadded IS NOT NULL + AND e.has_video_file = TRUE + ORDER BY e.imdb_id, e.season, e.episode + LIMIT 500 + """) + episodes = [dict(row) for row in cursor.fetchall()] + + # Check each episode's NFO file + missing_dateadded = [] + nfo_manager = dependencies["nfo_manager"] + + for ep in episodes: + try: + # Build expected NFO path + series_path = Path(ep['series_path']) + season_dir = series_path / config.tv_season_dir_format.format(season=ep['season']) + + if not season_dir.exists(): + continue + + # Find NFO file for this episode + nfo_files = list(season_dir.glob(f"*S{ep['season']:02d}E{ep['episode']:02d}*.nfo")) + if not nfo_files: + continue + + nfo_path = nfo_files[0] # Use first match + + # Parse NFO and check for dateadded + try: + import xml.etree.ElementTree as ET + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find('dateadded') + + if dateadded_elem is None or not dateadded_elem.text: + missing_dateadded.append({ + **ep, + 'nfo_path': str(nfo_path), + 'nfo_exists': True, + 'dateadded_in_nfo': False + }) + except ET.ParseError: + # NFO file is corrupted + missing_dateadded.append({ + **ep, + 'nfo_path': str(nfo_path), + 'nfo_exists': True, + 'dateadded_in_nfo': False, + 'nfo_corrupt': True + }) + + except Exception as e: + print(f"Error checking NFO for {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}: {e}") + continue + + return { + "success": True, + "total_episodes_checked": len(episodes), + "missing_dateadded_count": len(missing_dateadded), + "episodes": missing_dateadded[:50] # Limit display to first 50 + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": f"Failed to check NFO files: {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"] + config = dependencies["config"] + nfo_manager = dependencies["nfo_manager"] + + if not config.manage_nfo: + return { + "success": False, + "message": "NFO management is disabled in configuration" + } + + try: + # Build query based on parameters + if fix_all: + query = """ + SELECT e.imdb_id, e.season, e.episode, e.aired, e.dateadded, e.source, + s.path as series_path + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.dateadded IS NOT NULL + AND e.has_video_file = TRUE + ORDER BY e.imdb_id, e.season, e.episode + """ + params = [] + elif imdb_ids: + placeholders = ','.join(['%s'] * len(imdb_ids)) + query = f""" + SELECT e.imdb_id, e.season, e.episode, e.aired, e.dateadded, e.source, + s.path as series_path + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.imdb_id IN ({placeholders}) + AND e.dateadded IS NOT NULL + AND e.has_video_file = TRUE + ORDER BY e.imdb_id, e.season, e.episode + """ + params = imdb_ids + else: + return { + "success": False, + "message": "No IMDb IDs specified and fix_all not enabled" + } + + # Get episodes to update + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(query, params) + episodes = [dict(row) for row in cursor.fetchall()] + + if not episodes: + return { + "success": True, + "message": "No episodes found to update", + "updated_count": 0 + } + + # Update NFO files + updated_count = 0 + errors = [] + + for ep in episodes: + try: + series_path = Path(ep['series_path']) + season_dir = series_path / config.tv_season_dir_format.format(season=ep['season']) + + if not season_dir.exists(): + continue + + # Update NFO file + nfo_manager.create_episode_nfo( + season_dir=season_dir, + season_num=ep['season'], + episode_num=ep['episode'], + aired=ep['aired'], + dateadded=ep['dateadded'], + source=ep['source'], + lock_metadata=config.lock_metadata + ) + + updated_count += 1 + print(f"✅ Updated NFO: {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}") + + except Exception as e: + error_msg = f"Failed to update {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}: {str(e)}" + errors.append(error_msg) + print(f"❌ {error_msg}") + + return { + "success": True, + "message": f"Updated {updated_count} NFO files", + "updated_count": updated_count, + "total_episodes": len(episodes), + "errors": errors[:10] # Limit errors to first 10 + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "message": f"Bulk NFO update failed: {str(e)}" + } + + +# Add database admin routes to the web interface +async def register_database_admin_routes(app, dependencies): + """Register database admin routes""" + + @app.post("/api/admin/database/query") + async def api_database_query(query: str, limit: int = 100): + """Execute a database query""" + return await execute_database_query(dependencies, query, limit) + + @app.get("/api/admin/nfo/missing-dateadded") + async def api_episodes_missing_nfo_dateadded(): + """Get episodes missing dateadded in NFO files""" + return await get_episodes_missing_nfo_dateadded(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""" + return await bulk_update_nfo_files(dependencies, imdb_ids, fix_all) + + @app.get("/api/admin/database/quick-queries") + async def api_database_quick_queries(): + """Get predefined quick queries for common tasks""" + return { + "success": True, + "queries": [ + { + "name": "Episodes missing dateadded in NFO", + "description": "Find episodes with dateadded in DB but missing from NFO files", + "query": "SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, s.path FROM episodes e JOIN series s ON e.imdb_id = s.imdb_id WHERE e.dateadded IS NOT NULL AND e.has_video_file = TRUE ORDER BY e.last_updated DESC" + }, + { + "name": "Recent webhook episodes", + "description": "Episodes processed in last 24 hours", + "query": "SELECT * FROM episodes WHERE last_updated > NOW() - INTERVAL '1 day' ORDER BY last_updated DESC" + }, + { + "name": "Episodes by source type", + "description": "Count episodes by their date source", + "query": "SELECT source, COUNT(*) as count FROM episodes GROUP BY source ORDER BY count DESC" + }, + { + "name": "Series with most episodes", + "description": "Series sorted by episode count", + "query": "SELECT s.imdb_id, s.path, COUNT(e.episode) as episode_count FROM series s LEFT JOIN episodes e ON s.imdb_id = e.imdb_id GROUP BY s.imdb_id, s.path ORDER BY episode_count DESC" + }, + { + "name": "Episodes without video files", + "description": "Episodes in database without video files", + "query": "SELECT imdb_id, season, episode, dateadded, source FROM episodes WHERE has_video_file = FALSE ORDER BY imdb_id, season, episode" + } + ] + } \ No newline at end of file From 18d1b4560981fa0b8092d0cdf5fb77b8e2710dee Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:10:28 -0400 Subject: [PATCH 27/56] web: new scans to interface --- nfoguard-web/static/css/styles.css | 192 ++++++++++++++- nfoguard-web/static/index.html | 78 +++++- nfoguard-web/static/js/app.js | 379 +++++++++++++++++++++++++++++ 3 files changed, 647 insertions(+), 2 deletions(-) diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css index ad39481..79e36fb 100644 --- a/nfoguard-web/static/css/styles.css +++ b/nfoguard-web/static/css/styles.css @@ -886,4 +886,194 @@ body { .d-block { display: block; } .d-flex { display: flex; } .justify-content-between { justify-content: space-between; } -.align-items-center { align-items: center; } \ No newline at end of file +.align-items-center { align-items: center; } + +/* Database Admin Tools Styles */ +.query-results { + margin-top: 1rem; + padding: 1rem; + background: #f8f9fa; + border: 1px solid var(--border-color); + border-radius: 0.375rem; +} + +.query-info { + padding: 1rem; + border-radius: 0.375rem; + margin-bottom: 1rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; +} + +.query-info.success { + background-color: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; +} + +.query-info.error { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; +} + +.results-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + background: white; + border-radius: 0.375rem; + overflow: hidden; + box-shadow: var(--shadow); +} + +.results-table th { + background-color: var(--dark-color); + color: white; + padding: 0.75rem; + text-align: left; + font-weight: 600; +} + +.results-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border-color); + vertical-align: top; +} + +.results-table tr:hover { + background-color: #f8f9fa; +} + +.results-table td em { + color: var(--text-muted); + font-style: italic; +} + +.table-container { + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 0.375rem; +} + +.admin-tools { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.admin-tools .btn { + text-align: left; + justify-content: flex-start; +} + +.admin-results { + margin-top: 1rem; + padding: 1rem; + background: #f8f9fa; + border: 1px solid var(--border-color); + border-radius: 0.375rem; +} + +.scan-summary, +.fix-summary { + padding: 1rem; + border-radius: 0.375rem; + margin-bottom: 1rem; +} + +.scan-summary { + background-color: #e7f3ff; + border: 1px solid #b3d9ff; +} + +.fix-summary.success { + background-color: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; +} + +.fix-errors { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + padding: 1rem; + border-radius: 0.375rem; + margin-top: 1rem; +} + +.fix-errors ul { + margin: 0.5rem 0 0 1rem; +} + +.missing-episodes { + margin-top: 1rem; +} + +.loading { + text-align: center; + padding: 2rem; + color: var(--text-muted); + font-style: italic; +} + +.loading::before { + content: "⏳ "; +} + +.success { + background-color: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; + padding: 1rem; + border-radius: 0.375rem; + margin: 1rem 0; +} + +.error { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 1rem; + border-radius: 0.375rem; + margin: 1rem 0; +} + +/* Make text areas more distinctive */ +textarea { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; + resize: vertical; +} + +/* Button variations for admin tools */ +.btn.btn-info { + background-color: #17a2b8; + border-color: #17a2b8; + color: white; +} + +.btn.btn-info:hover { + background-color: #138496; + border-color: #117a8b; +} + +/* Responsive adjustments for admin tools */ +@media (max-width: 768px) { + .table-container { + max-height: 300px; + } + + .results-table { + font-size: 0.75rem; + } + + .results-table th, + .results-table td { + padding: 0.5rem; + } + + .admin-tools { + gap: 0.75rem; + } +} \ No newline at end of file diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index f434527..365a6fb 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -390,6 +390,82 @@ Refresh Stats + + +
+

Database Query Tool

+

Execute custom SQL queries on the NFOGuard database

+
+
+ + +
+
+ + + Query will be automatically limited to 100 results for safety +
+
+ + +
+ +
+ +
+ + +
+

NFO File Repair

+

Fix missing <dateadded> elements in NFO files

+
+ +
+ +
+ + +
+

Database Admin

+

Advanced database management tools

+
+ + + +
+ +
@@ -457,6 +533,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 14f72ff..fbb9535 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1676,4 +1676,383 @@ function stopScanStatusPolling() { clearInterval(scanStatusInterval); scanStatusInterval = null; } +} + +// ============================================================================ +// DATABASE ADMIN TOOLS +// ============================================================================ + +// Initialize database admin tools +document.addEventListener('DOMContentLoaded', function() { + initializeDatabaseAdminTools(); +}); + +function initializeDatabaseAdminTools() { + // Load quick queries + loadQuickQueries(); + + // Initialize database query form + const dbQueryForm = document.getElementById('database-query-form'); + if (dbQueryForm) { + dbQueryForm.addEventListener('submit', function(e) { + e.preventDefault(); + executeDatabaseQuery(); + }); + } +} + +async function loadQuickQueries() { + try { + const response = await apiCall('/api/admin/database/quick-queries'); + const select = document.getElementById('quick-query-select'); + + if (response.success && select) { + // Clear existing options except first + select.innerHTML = ''; + + response.queries.forEach(query => { + const option = document.createElement('option'); + option.value = query.query; + option.textContent = `${query.name} - ${query.description}`; + select.appendChild(option); + }); + } + } catch (error) { + console.error('Failed to load quick queries:', error); + } +} + +function loadQuickQuery() { + const select = document.getElementById('quick-query-select'); + const textarea = document.getElementById('sql-query'); + + if (select && textarea && select.value) { + textarea.value = select.value; + } +} + +async function executeDatabaseQuery() { + const query = document.getElementById('sql-query').value.trim(); + const limit = document.getElementById('query-limit').value; + const resultsDiv = document.getElementById('query-results'); + const resultsContent = document.getElementById('query-results-content'); + + if (!query) { + alert('Please enter a SQL query'); + return; + } + + try { + resultsContent.innerHTML = '
Executing query...
'; + resultsDiv.style.display = 'block'; + + const response = await apiCall('/api/admin/database/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, limit: parseInt(limit) }) + }); + + if (response.success) { + if (response.columns && response.rows) { + // SELECT query results + let html = ` +
+ Query: ${response.query}
+ Rows returned: ${response.row_count} +
+
+ + + + `; + + response.columns.forEach(col => { + html += ``; + }); + html += ''; + + response.rows.forEach(row => { + html += ''; + response.columns.forEach(col => { + const value = row[col]; + html += ``; + }); + html += ''; + }); + + html += '
${col}
${value !== null ? value : 'NULL'}
'; + resultsContent.innerHTML = html; + } else { + // Non-SELECT query + resultsContent.innerHTML = ` +
+ Query: ${response.query}
+ Result: ${response.message}
+ Rows affected: ${response.rows_affected} +
+ `; + } + } else { + resultsContent.innerHTML = ` +
+ Error: ${response.error}
+ Query: ${response.query} +
+ `; + } + } catch (error) { + resultsContent.innerHTML = ` +
+ Error: Failed to execute query: ${error.message} +
+ `; + } +} + +// NFO File Repair Functions +async function checkMissingNFODates() { + const resultsDiv = document.getElementById('nfo-scan-results'); + const resultsContent = document.getElementById('nfo-scan-content'); + + try { + resultsContent.innerHTML = '
Scanning NFO files for missing dateadded elements...
'; + resultsDiv.style.display = 'block'; + + const response = await apiCall('/api/admin/nfo/missing-dateadded'); + + if (response.success) { + let html = ` +
+

NFO Scan Results

+

Total episodes checked: ${response.total_episodes_checked}

+

Episodes missing dateadded in NFO: ${response.missing_dateadded_count}

+
+ `; + + if (response.missing_dateadded_count > 0) { + html += ` +
+
Episodes with missing dateadded (showing first 50):
+
+ + + + + + + + + + + + + `; + + response.episodes.forEach(ep => { + html += ` + + + + + + + + + `; + }); + + html += '
SeriesSeasonEpisodeDatabase DateSourceNFO Path
${ep.imdb_id}${ep.season}${ep.episode}${new Date(ep.dateadded).toLocaleString()}${ep.source}${ep.nfo_path}
'; + } else { + html += '
✅ All NFO files have dateadded elements!
'; + } + + resultsContent.innerHTML = html; + } else { + resultsContent.innerHTML = `
Error: ${response.message}
`; + } + } catch (error) { + resultsContent.innerHTML = `
Failed to scan NFO files: ${error.message}
`; + } +} + +async function fixAllMissingNFODates() { + if (!confirm('This will update all NFO files that are missing dateadded elements. Continue?')) { + return; + } + + const resultsContent = document.getElementById('nfo-scan-content'); + + try { + resultsContent.innerHTML = '
Fixing all missing dateadded elements in NFO files...
'; + + const response = await apiCall('/api/admin/nfo/bulk-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fix_all: true }) + }); + + if (response.success) { + let html = ` +
+

NFO Fix Results

+

${response.updated_count} NFO files updated successfully

+

Total episodes processed: ${response.total_episodes}

+

Message: ${response.message}

+
+ `; + + if (response.errors && response.errors.length > 0) { + html += ` +
+
Errors (first 10):
+
    + `; + response.errors.forEach(error => { + html += `
  • ${error}
  • `; + }); + html += '
'; + } + + resultsContent.innerHTML = html; + } else { + resultsContent.innerHTML = `
Error: ${response.message}
`; + } + } catch (error) { + resultsContent.innerHTML = `
Failed to fix NFO files: ${error.message}
`; + } +} + +async function fixSpecificSeries() { + const imdbIds = prompt('Enter IMDb IDs to fix (comma-separated):\nExample: tt9111220,tt12327578'); + + if (!imdbIds) return; + + const idArray = imdbIds.split(',').map(id => id.trim()).filter(id => id); + + if (idArray.length === 0) { + alert('Please enter valid IMDb IDs'); + return; + } + + const resultsContent = document.getElementById('nfo-scan-content'); + + try { + resultsContent.innerHTML = '
Fixing NFO files for specified series...
'; + + const response = await apiCall('/api/admin/nfo/bulk-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ imdb_ids: idArray }) + }); + + if (response.success) { + let html = ` +
+

NFO Fix Results for Specific Series

+

${response.updated_count} NFO files updated successfully

+

Series processed: ${idArray.join(', ')}

+

Message: ${response.message}

+
+ `; + + resultsContent.innerHTML = html; + } else { + resultsContent.innerHTML = `
Error: ${response.message}
`; + } + } catch (error) { + resultsContent.innerHTML = `
Failed to fix NFO files: ${error.message}
`; + } +} + +// Database Admin Functions +async function showEpisodesMissingNFODateadded() { + const resultsDiv = document.getElementById('admin-results'); + const resultsContent = document.getElementById('admin-results-content'); + + try { + resultsContent.innerHTML = '
Checking for episodes missing NFO dateadded...
'; + resultsDiv.style.display = 'block'; + + const response = await apiCall('/api/admin/nfo/missing-dateadded'); + + if (response.success) { + 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!

' + } + `; + resultsContent.innerHTML = html; + } else { + resultsContent.innerHTML = `
Error: ${response.message}
`; + } + } catch (error) { + resultsContent.innerHTML = `
Failed to check NFO files: ${error.message}
`; + } +} + +async function exportDatabaseReport() { + alert('Database export functionality will be implemented in a future update.'); +} + +async function showRecentWebhookActivity() { + const resultsDiv = document.getElementById('admin-results'); + const resultsContent = document.getElementById('admin-results-content'); + + try { + resultsContent.innerHTML = '
Loading recent webhook activity...
'; + resultsDiv.style.display = 'block'; + + const response = await apiCall('/api/admin/database/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: "SELECT * FROM episodes WHERE last_updated > NOW() - INTERVAL '24 hours' ORDER BY last_updated DESC", + limit: 20 + }) + }); + + if (response.success && response.rows) { + let html = ` +

Recent Webhook Activity (Last 24 Hours)

+

Episodes processed: ${response.row_count}

+
+ + + + + + + + + + + + + `; + + response.rows.forEach(row => { + html += ` + + + + + + + + + `; + }); + + html += '
IMDb IDSeasonEpisodeDate AddedSourceLast Updated
${row.imdb_id}${row.season}${row.episode}${row.dateadded ? new Date(row.dateadded).toLocaleString() : 'None'}${row.source}${new Date(row.last_updated).toLocaleString()}
'; + resultsContent.innerHTML = html; + } else { + resultsContent.innerHTML = '

No recent webhook activity found.

'; + } + } catch (error) { + resultsContent.innerHTML = `
Failed to load webhook activity: ${error.message}
`; + } } \ No newline at end of file From 2a972f0e149450e766541a2833f8c4062a51873a Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:25:59 -0400 Subject: [PATCH 28/56] web: fix web routes --- api/web_routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 1e7efb5..e85be8c 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1461,7 +1461,7 @@ def register_web_routes(app, dependencies): return {"scanning": False, "message": f"Unable to check scan status: {str(e)}"} # Register database admin routes - await register_database_admin_routes(app, dependencies) + register_database_admin_routes(app, dependencies) # ============================================================================= @@ -1701,7 +1701,7 @@ async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_a # Add database admin routes to the web interface -async def register_database_admin_routes(app, dependencies): +def register_database_admin_routes(app, dependencies): """Register database admin routes""" @app.post("/api/admin/database/query") From cb46b5624367e6020bc3c89b9206a46abda1203b Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:29:35 -0400 Subject: [PATCH 29/56] web: fix manual date fixes --- api/web_routes.py | 40 ++++------------------------------------ 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index e85be8c..075e6db 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -597,42 +597,10 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi has_video_file=episode_data.get('has_video_file', False) ) - # Create/update NFO file with new data - nfo_manager = dependencies["nfo_manager"] - config = dependencies["config"] - - if config.manage_nfo: - try: - # Find the series directory based on IMDb ID - series_path = None - for tv_path in config.tv_paths: - for series_dir in Path(tv_path).iterdir(): - if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower(): - series_path = series_dir - break - if series_path: - break - - if series_path: - season_dir = series_path / config.tv_season_dir_format.format(season=season) - if season_dir.exists(): - nfo_manager.create_episode_nfo( - season_dir=season_dir, - season_num=season, - episode_num=episode, - aired=episode_data.get('aired'), - dateadded=dateadded, - source=source, - lock_metadata=config.lock_metadata - ) - print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}") - else: - print(f"⚠️ Season directory not found: {season_dir}") - else: - print(f"⚠️ Series directory not found for {imdb_id}") - - except Exception as e: - print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}") + # Note: NFO file updates are handled by the core container + # The web container only updates the database + print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}") + print(f"ℹ️ NFO file updates will be handled by the core container on next scan") # Add to processing history db.add_processing_history( From 1d837102e922304534fed1b0d004e8f73c618f0e Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:32:59 -0400 Subject: [PATCH 30/56] web: manual update debug --- api/routes.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++ api/web_routes.py | 44 ++++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/api/routes.py b/api/routes.py index e46409d..d61395f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2083,6 +2083,59 @@ def stop_scan_tracking(): # Route Registration # --------------------------- +async def update_episode_nfo(imdb_id: str, season: int, episode: int, request: Request, dependencies: dict): + """Update NFO file for a specific episode with new dateadded""" + try: + # Get request data + payload = await request.json() + dateadded = payload.get('dateadded') + source = payload.get('source', 'manual') + aired = payload.get('aired') + + # Get dependencies + config = dependencies["config"] + nfo_manager = dependencies["nfo_manager"] + + if not config.manage_nfo: + return {"success": False, "message": "NFO management is disabled"} + + # Find the series directory based on IMDb ID + series_path = None + for tv_path in config.tv_paths: + for series_dir in Path(tv_path).iterdir(): + if series_dir.is_dir() and imdb_id.lower() in series_dir.name.lower(): + series_path = series_dir + break + if series_path: + break + + if not series_path: + return {"success": False, "message": f"Series directory not found for {imdb_id}"} + + # Get season directory + season_dir = series_path / config.tv_season_dir_format.format(season=season) + if not season_dir.exists(): + return {"success": False, "message": f"Season directory not found: {season_dir}"} + + # Update NFO file + nfo_manager.create_episode_nfo( + season_dir=season_dir, + season_num=season, + episode_num=episode, + aired=aired, + dateadded=dateadded, + source=source, + lock_metadata=config.lock_metadata + ) + + print(f"✅ Updated NFO file for {imdb_id} S{season:02d}E{episode:02d}") + return {"success": True, "message": f"NFO file updated for {imdb_id} S{season:02d}E{episode:02d}"} + + except Exception as e: + print(f"❌ Error updating NFO file for {imdb_id} S{season:02d}E{episode:02d}: {e}") + return {"success": False, "message": f"Failed to update NFO file: {str(e)}"} + + def register_routes(app, dependencies: dict): """ Register all routes with the FastAPI app @@ -2136,6 +2189,10 @@ def register_routes(app, dependencies: dict): @app.delete("/database/episode/{imdb_id}/{season}/{episode}") async def _delete_episode(imdb_id: str, season: int, episode: int): return await delete_episode(imdb_id, season, episode, dependencies) + + @app.post("/api/episodes/{imdb_id}/{season}/{episode}/update-nfo") + async def _update_episode_nfo(imdb_id: str, season: int, episode: int, request: Request): + return await update_episode_nfo(imdb_id, season, episode, request, dependencies) @app.delete("/database/series/{imdb_id}/episodes") async def _delete_series_episodes(imdb_id: str): diff --git a/api/web_routes.py b/api/web_routes.py index 075e6db..6990bb7 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -597,10 +597,46 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi has_video_file=episode_data.get('has_video_file', False) ) - # Note: NFO file updates are handled by the core container - # The web container only updates the database - print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}") - print(f"ℹ️ NFO file updates will be handled by the core container on next scan") + # Trigger NFO file update via core container + try: + import urllib.request + import urllib.parse + import json + import os + + # Get core container connection details + core_host = os.environ.get("CORE_API_HOST", "nfoguard-core") + core_port = os.environ.get("CORE_API_PORT", "8080") + + # Call core container to update NFO file + nfo_update_url = f"http://{core_host}:{core_port}/api/episodes/{imdb_id}/{season}/{episode}/update-nfo" + + # Create request data + request_data = { + "dateadded": dateadded, + "source": source, + "aired": episode_data.get('aired').isoformat() if episode_data.get('aired') else None + } + + # Make POST request to core container + req = urllib.request.Request( + nfo_update_url, + data=json.dumps(request_data).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST' + ) + + with urllib.request.urlopen(req, timeout=10) as response: + if response.status == 200: + print(f"✅ Database and NFO file updated for {imdb_id} S{season:02d}E{episode:02d}") + else: + print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}") + print(f"⚠️ NFO file update failed (HTTP {response.status})") + + except Exception as e: + print(f"✅ Database updated for {imdb_id} S{season:02d}E{episode:02d}") + print(f"⚠️ NFO file update failed: {e}") + print(f"ℹ️ NFO will be updated on next scan") # Add to processing history db.add_processing_history( From eb66af837f5cad37c4ce155ce35b397414ccfea2 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:39:57 -0400 Subject: [PATCH 31/56] web: debug --- api/web_routes.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api/web_routes.py b/api/web_routes.py index 6990bb7..e1f9db5 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -581,11 +581,25 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi """Update dateadded for a specific episode""" db = dependencies["db"] + print(f"🔍 DEBUG: update_episode_date called with dateadded={repr(dateadded)}, source={repr(source)}") + # Get existing episode episode_data = db.get_episode_date(imdb_id, season, episode) if not episode_data: raise HTTPException(status_code=404, detail="Episode not found") + # Handle special sources + if source == "airdate" and episode_data.get('aired'): + # Use aired date as dateadded for airdate source + aired_date = episode_data.get('aired') + if hasattr(aired_date, 'isoformat'): + dateadded = aired_date.isoformat() + "T20:00:00" # Set to 8 PM on air date + else: + dateadded = str(aired_date) + "T20:00:00" + print(f"🔍 DEBUG: Using air date as dateadded: {dateadded}") + + print(f"🔍 DEBUG: Final dateadded value: {repr(dateadded)}") + # Update the date db.upsert_episode_date( imdb_id=imdb_id, From 4516a80208279d1517a5bee2cf37b5bae20b0959 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:45:46 -0400 Subject: [PATCH 32/56] web: debugging --- nfoguard-web/static/css/styles.css | 3 ++- nfoguard-web/static/index.html | 4 ++-- nfoguard-web/static/js/app.js | 17 +++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css index 79e36fb..0c06736 100644 --- a/nfoguard-web/static/css/styles.css +++ b/nfoguard-web/static/css/styles.css @@ -950,7 +950,8 @@ body { font-style: italic; } -.table-container { +.query-results .table-container, +.admin-results .table-container { max-height: 400px; overflow-y: auto; border: 1px solid var(--border-color); diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 365a6fb..33ee3c1 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -4,7 +4,7 @@ NFOGuard - Database Management - + @@ -533,6 +533,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index fbb9535..2b08304 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1047,18 +1047,23 @@ async function handleEnhancedEditSubmit(event) { const dateadded = document.getElementById('edit-dateadded').value; const source = document.getElementById('edit-source').value; - if (!dateadded) { + if (!dateadded && source !== 'airdate') { showToast('Please enter a date', 'warning'); return; } // Convert datetime-local to ISO string with error handling let isoDateadded = null; - try { - isoDateadded = new Date(dateadded).toISOString(); - } catch (e) { - showToast('Invalid date format', 'error'); - return; + if (dateadded) { + try { + isoDateadded = new Date(dateadded).toISOString(); + } catch (e) { + showToast('Invalid date format', 'error'); + return; + } + } else if (source === 'airdate') { + // For airdate source, let the backend handle the date conversion + isoDateadded = null; } try { From ee403f9ac782cad4b6aa4bef52423fced64d1d69 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:47:47 -0400 Subject: [PATCH 33/56] web: debugs --- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 33ee3c1..614caf4 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -533,6 +533,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 2b08304..dea032a 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1047,6 +1047,13 @@ async function handleEnhancedEditSubmit(event) { const dateadded = document.getElementById('edit-dateadded').value; const source = document.getElementById('edit-source').value; + console.log('🔍 DEBUG: handleEnhancedEditSubmit called with:', { + dateadded: dateadded, + source: source, + mediaType: mediaType, + imdbId: imdbId + }); + if (!dateadded && source !== 'airdate') { showToast('Please enter a date', 'warning'); return; From 5d3754b401ea605f098edde1de9bfdeb86817012 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 11:54:45 -0400 Subject: [PATCH 34/56] web: more date add debugs --- api/web_routes.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index e1f9db5..68908fc 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1315,9 +1315,16 @@ def register_web_routes(app, dependencies): return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) @app.put("/api/episodes/{imdb_id}/{season}/{episode}") - async def api_update_episode(imdb_id: str, season: int, episode: int, - dateadded: str = None, source: str = "manual"): - return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) + async def api_update_episode(imdb_id: str, season: int, episode: int, request: Request): + try: + # Parse JSON body + data = await request.json() + dateadded = data.get('dateadded') + source = data.get('source', 'manual') + return await update_episode_date(dependencies, imdb_id, season, episode, dateadded, source) + except Exception as e: + print(f"❌ Error parsing episode update request: {e}") + raise HTTPException(status_code=400, detail=f"Invalid request format: {str(e)}") @app.get("/api/episodes/{imdb_id}/{season}/{episode}/date-options") async def api_episode_date_options(imdb_id: str, season: int, episode: int): From 4d7d9752fc7bca37f4e992ee61a1e4895fa00dae Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:11:11 -0400 Subject: [PATCH 35/56] web: debug with dates --- api/web_routes.py | 107 ++++++++++++++++++++++++--------- nfoguard-web/static/index.html | 2 +- nfoguard-web/static/js/app.js | 27 +++++---- 3 files changed, 95 insertions(+), 41 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 68908fc..4a81434 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1538,46 +1538,76 @@ async def execute_database_query(dependencies: dict, query: str, limit: int = 10 async def get_episodes_missing_nfo_dateadded(dependencies: dict): - """Find episodes that have dateadded in database but missing from NFO files""" + """Find episodes and movies that have dateadded in database but missing from NFO files""" db = dependencies["db"] config = dependencies["config"] try: - # Get all episodes with dateadded in database + # Get all episodes with video files (even if dateadded is NULL, we can check if they should have dates) with db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, e.has_video_file, - s.path as series_path + s.path as series_path, 'episode' as media_type FROM episodes e JOIN series s ON e.imdb_id = s.imdb_id - WHERE e.dateadded IS NOT NULL - AND e.has_video_file = TRUE + WHERE e.has_video_file = TRUE ORDER BY e.imdb_id, e.season, e.episode - LIMIT 500 + LIMIT 2000 """) episodes = [dict(row) for row in cursor.fetchall()] + + # Also get movies with video files + cursor.execute(""" + SELECT m.imdb_id, NULL as season, NULL as episode, m.dateadded, m.source, m.has_video_file, + m.path as series_path, 'movie' as media_type + FROM movies m + WHERE m.has_video_file = TRUE + ORDER BY m.imdb_id + LIMIT 2000 + """) + movies = [dict(row) for row in cursor.fetchall()] + + # Combine episodes and movies + all_items = episodes + movies - # Check each episode's NFO file + # Check each item's NFO file missing_dateadded = [] nfo_manager = dependencies["nfo_manager"] - for ep in episodes: + for item in all_items: try: - # Build expected NFO path - series_path = Path(ep['series_path']) - season_dir = series_path / config.tv_season_dir_format.format(season=ep['season']) + media_type = item['media_type'] + item_path = Path(item['series_path']) - if not season_dir.exists(): + if media_type == 'episode': + # Handle TV episodes + season_dir = item_path / config.tv_season_dir_format.format(season=item['season']) + + if not season_dir.exists(): + continue + + # Find NFO file for this episode + nfo_files = list(season_dir.glob(f"*S{item['season']:02d}E{item['episode']:02d}*.nfo")) + if not nfo_files: + continue + + nfo_path = nfo_files[0] # Use first match + + elif media_type == 'movie': + # Handle movies + if not item_path.exists(): + continue + + # Find NFO file for this movie + nfo_files = list(item_path.glob("*.nfo")) + if not nfo_files: + continue + + nfo_path = nfo_files[0] # Use first match + else: continue - # Find NFO file for this episode - nfo_files = list(season_dir.glob(f"*S{ep['season']:02d}E{ep['episode']:02d}*.nfo")) - if not nfo_files: - continue - - nfo_path = nfo_files[0] # Use first match - # Parse NFO and check for dateadded try: import xml.etree.ElementTree as ET @@ -1585,17 +1615,31 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): root = tree.getroot() dateadded_elem = root.find('dateadded') - if dateadded_elem is None or not dateadded_elem.text: - missing_dateadded.append({ - **ep, - 'nfo_path': str(nfo_path), - 'nfo_exists': True, - 'dateadded_in_nfo': False - }) + # Check if dateadded is missing or empty + has_dateadded = dateadded_elem is not None and dateadded_elem.text and dateadded_elem.text.strip() + + # If NFO is missing dateadded, check if item should have a date + if not has_dateadded: + # Item should have a date if it has one in the database, or if it has aired date + should_have_date = ( + item['dateadded'] is not None or + (media_type == 'episode' and item.get('aired')) or + media_type == 'movie' # Movies should generally have dateadded + ) + + if should_have_date: + missing_dateadded.append({ + **item, + 'nfo_path': str(nfo_path), + 'nfo_exists': True, + 'dateadded_in_nfo': False, + 'should_have_date': True + }) + except ET.ParseError: # NFO file is corrupted missing_dateadded.append({ - **ep, + **item, 'nfo_path': str(nfo_path), 'nfo_exists': True, 'dateadded_in_nfo': False, @@ -1603,14 +1647,19 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): }) except Exception as e: - print(f"Error checking NFO for {ep['imdb_id']} S{ep['season']:02d}E{ep['episode']:02d}: {e}") + if item['media_type'] == 'episode': + print(f"Error checking NFO for {item['imdb_id']} S{item['season']:02d}E{item['episode']:02d}: {e}") + else: + print(f"Error checking NFO for movie {item['imdb_id']}: {e}") continue return { "success": True, "total_episodes_checked": len(episodes), + "total_movies_checked": len(movies), + "total_items_checked": len(all_items), "missing_dateadded_count": len(missing_dateadded), - "episodes": missing_dateadded[:50] # Limit display to first 50 + "items": missing_dateadded[:50] # Limit display to first 50 } except Exception as e: diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index 614caf4..d4860b9 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -533,6 +533,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index dea032a..1eb8e5a 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1836,20 +1836,23 @@ async function checkMissingNFODates() { let html = `

NFO Scan Results

-

Total episodes checked: ${response.total_episodes_checked}

-

Episodes missing dateadded in NFO: ${response.missing_dateadded_count}

+

Total episodes checked: ${response.total_episodes_checked || 0}

+

Total movies checked: ${response.total_movies_checked || 0}

+

Total items checked: ${response.total_items_checked || 0}

+

Items missing dateadded in NFO: ${response.missing_dateadded_count}

`; if (response.missing_dateadded_count > 0) { html += `
-
Episodes with missing dateadded (showing first 50):
+
Items with missing dateadded (showing first 50):
- + + @@ -1860,15 +1863,17 @@ async function checkMissingNFODates() { `; - response.episodes.forEach(ep => { + (response.items || response.episodes || []).forEach(item => { + const displayDate = item.dateadded ? new Date(item.dateadded).toLocaleString() : 'None'; html += ` - - - - - - + + + + + + + `; }); From 319838d305aadb85a490dd0abff2ff84fd8b0a26 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:14:06 -0400 Subject: [PATCH 36/56] web: debug nfo fix --- api/web_routes.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/api/web_routes.py b/api/web_routes.py index 4a81434..c9e07a0 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1575,7 +1575,15 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): missing_dateadded = [] nfo_manager = dependencies["nfo_manager"] + print(f"🔍 DEBUG: Starting NFO scan of {len(all_items)} items...") + processed_count = 0 + nfo_found_count = 0 + nfo_missing_dateadded_count = 0 + for item in all_items: + processed_count += 1 + if processed_count <= 10 or processed_count % 100 == 0: # Log progress + print(f"🔍 DEBUG: Processing item {processed_count}/{len(all_items)}: {item['imdb_id']}") try: media_type = item['media_type'] item_path = Path(item['series_path']) @@ -1585,29 +1593,42 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): season_dir = item_path / config.tv_season_dir_format.format(season=item['season']) if not season_dir.exists(): + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: Season dir doesn't exist: {season_dir}") continue # Find NFO file for this episode nfo_files = list(season_dir.glob(f"*S{item['season']:02d}E{item['episode']:02d}*.nfo")) if not nfo_files: + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: No NFO found for {item['imdb_id']} S{item['season']:02d}E{item['episode']:02d} in {season_dir}") continue nfo_path = nfo_files[0] # Use first match + nfo_found_count += 1 elif media_type == 'movie': # Handle movies if not item_path.exists(): + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: Movie path doesn't exist: {item_path}") continue # Find NFO file for this movie nfo_files = list(item_path.glob("*.nfo")) if not nfo_files: + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: No NFO found for movie {item['imdb_id']} in {item_path}") continue nfo_path = nfo_files[0] # Use first match + nfo_found_count += 1 else: continue + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: Found NFO file: {nfo_path}") + # Parse NFO and check for dateadded try: import xml.etree.ElementTree as ET @@ -1618,8 +1639,15 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): # Check if dateadded is missing or empty has_dateadded = dateadded_elem is not None and dateadded_elem.text and dateadded_elem.text.strip() + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: NFO {nfo_path} - dateadded element: {dateadded_elem}") + print(f"🔍 DEBUG: dateadded text: {dateadded_elem.text if dateadded_elem is not None else 'None'}") + print(f"🔍 DEBUG: has_dateadded: {has_dateadded}") + # If NFO is missing dateadded, check if item should have a date if not has_dateadded: + nfo_missing_dateadded_count += 1 + # Item should have a date if it has one in the database, or if it has aired date should_have_date = ( item['dateadded'] is not None or @@ -1627,6 +1655,9 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): media_type == 'movie' # Movies should generally have dateadded ) + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: should_have_date: {should_have_date} (db_date: {item['dateadded']}, aired: {item.get('aired')})") + if should_have_date: missing_dateadded.append({ **item, @@ -1636,6 +1667,9 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): 'should_have_date': True }) + if processed_count <= 5: # Debug first few + print(f"🔍 DEBUG: Added to missing list: {item['imdb_id']}") + except ET.ParseError: # NFO file is corrupted missing_dateadded.append({ @@ -1653,6 +1687,12 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): print(f"Error checking NFO for movie {item['imdb_id']}: {e}") continue + print(f"🔍 DEBUG: NFO scan complete!") + print(f"🔍 DEBUG: Total items: {len(all_items)}") + print(f"🔍 DEBUG: NFO files found: {nfo_found_count}") + print(f"🔍 DEBUG: NFO files missing dateadded: {nfo_missing_dateadded_count}") + print(f"🔍 DEBUG: Items that should have dateadded: {len(missing_dateadded)}") + return { "success": True, "total_episodes_checked": len(episodes), From 254c9c9c5f9d3e3cf6e6afd32ea8151d56fe1aad Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:30:29 -0400 Subject: [PATCH 37/56] web: more debug --- api/routes.py | 138 ++++++++++++++++++++++++++++ api/web_routes.py | 229 ++++++++++++++-------------------------------- 2 files changed, 209 insertions(+), 158 deletions(-) diff --git a/api/routes.py b/api/routes.py index d61395f..36de384 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2277,6 +2277,144 @@ def register_routes(app, dependencies: dict): # - Health checks (/health) # # Web interface available on separate container port 8081 + @app.get("/admin/nfo-repair-scan") + async def nfo_repair_scan(dependencies: dict): + """Scan filesystem for episodes/movies missing dateadded elements in NFO files""" + from nfoguard.utils.db_utils import get_db_connection + import os + import xml.etree.ElementTree as ET + + logger = dependencies.get("logger") + config = dependencies.get("config") + + if not logger or not config: + raise HTTPException(status_code=500, detail="Dependencies not available") + + logger.info("🔧 Starting NFO repair scan from core container") + + missing_items = { + "episodes": [], + "movies": [] + } + + try: + # Get database connection + db_conn = get_db_connection(config) + if not db_conn: + raise HTTPException(status_code=500, detail="Database connection failed") + + # Scan episodes + logger.info("📺 Scanning episodes for missing dateadded elements") + episode_query = """ + SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded + FROM episodes + WHERE dateadded IS NOT NULL + ORDER BY imdb_id, season, episode + """ + + episodes = db_conn.fetch_all(episode_query) + logger.info(f"📊 Found {len(episodes)} episodes in database") + + for episode in episodes: + # Build NFO path + nfo_path = os.path.join( + config.tv_library_path, + episode["series_name"], + f"Season {episode['season']:02d}", + f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" + ) + + # Check if NFO file exists and has dateadded + if os.path.exists(nfo_path): + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + if dateadded_elem is None or not dateadded_elem.text: + missing_items["episodes"].append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": episode["series_name"], + "episode_name": episode["episode_name"], + "dateadded": episode["dateadded"], + "nfo_path": nfo_path + }) + except ET.ParseError as e: + logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + missing_items["episodes"].append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": episode["series_name"], + "episode_name": episode["episode_name"], + "dateadded": episode["dateadded"], + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" + }) + + # Scan movies + logger.info("🎬 Scanning movies for missing dateadded elements") + movie_query = """ + SELECT DISTINCT imdb_id, title, dateadded + FROM movies + WHERE dateadded IS NOT NULL + ORDER BY title + """ + + movies = db_conn.fetch_all(movie_query) + logger.info(f"📊 Found {len(movies)} movies in database") + + for movie in movies: + # Build NFO path + nfo_path = os.path.join( + config.movie_library_path, + movie["title"], + f"{movie['title']}.nfo" + ) + + # Check if NFO file exists and has dateadded + if os.path.exists(nfo_path): + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + if dateadded_elem is None or not dateadded_elem.text: + missing_items["movies"].append({ + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "dateadded": movie["dateadded"], + "nfo_path": nfo_path + }) + except ET.ParseError as e: + logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + missing_items["movies"].append({ + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "dateadded": movie["dateadded"], + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" + }) + + db_conn.close() + + total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) + logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") + + return { + "status": "success", + "total_missing": total_missing, + "episodes_missing": len(missing_items["episodes"]), + "movies_missing": len(missing_items["movies"]), + "missing_items": missing_items + } + + except Exception as e: + logger.error(f"❌ Error during NFO repair scan: {str(e)}") + raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}") + # --------------------------- # Core API - No Web Interface # --------------------------- diff --git a/api/web_routes.py b/api/web_routes.py index c9e07a0..f9e57e2 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1538,174 +1538,87 @@ async def execute_database_query(dependencies: dict, query: str, limit: int = 10 async def get_episodes_missing_nfo_dateadded(dependencies: dict): - """Find episodes and movies that have dateadded in database but missing from NFO files""" - db = dependencies["db"] + """Find episodes and movies missing dateadded elements in NFO files via core container""" config = dependencies["config"] try: - # Get all episodes with video files (even if dateadded is NULL, we can check if they should have dates) - with db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(""" - SELECT e.imdb_id, e.season, e.episode, e.dateadded, e.source, e.has_video_file, - s.path as series_path, 'episode' as media_type - FROM episodes e - JOIN series s ON e.imdb_id = s.imdb_id - WHERE e.has_video_file = TRUE - ORDER BY e.imdb_id, e.season, e.episode - LIMIT 2000 - """) - episodes = [dict(row) for row in cursor.fetchall()] + print("🔧 Calling core container for NFO repair scan...") + + # Call the core container's NFO repair scan endpoint + import httpx + + core_url = f"http://nfoguard-core:{config.core_api_port}/admin/nfo-repair-scan" + print(f"🔍 DEBUG: Calling core container at: {core_url}") + + async with httpx.AsyncClient(timeout=300.0) as client: # 5 minute timeout for filesystem scan + response = await client.get(core_url) - # Also get movies with video files - cursor.execute(""" - SELECT m.imdb_id, NULL as season, NULL as episode, m.dateadded, m.source, m.has_video_file, - m.path as series_path, 'movie' as media_type - FROM movies m - WHERE m.has_video_file = TRUE - ORDER BY m.imdb_id - LIMIT 2000 - """) - movies = [dict(row) for row in cursor.fetchall()] - - # Combine episodes and movies - all_items = episodes + movies - - # Check each item's NFO file - missing_dateadded = [] - nfo_manager = dependencies["nfo_manager"] - - print(f"🔍 DEBUG: Starting NFO scan of {len(all_items)} items...") - processed_count = 0 - nfo_found_count = 0 - nfo_missing_dateadded_count = 0 - - for item in all_items: - processed_count += 1 - if processed_count <= 10 or processed_count % 100 == 0: # Log progress - print(f"🔍 DEBUG: Processing item {processed_count}/{len(all_items)}: {item['imdb_id']}") - try: - media_type = item['media_type'] - item_path = Path(item['series_path']) + if response.status_code == 200: + result = response.json() + print(f"✅ Core container scan complete: {result['total_missing']} items missing dateadded") - if media_type == 'episode': - # Handle TV episodes - season_dir = item_path / config.tv_season_dir_format.format(season=item['season']) - - if not season_dir.exists(): - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: Season dir doesn't exist: {season_dir}") - continue - - # Find NFO file for this episode - nfo_files = list(season_dir.glob(f"*S{item['season']:02d}E{item['episode']:02d}*.nfo")) - if not nfo_files: - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: No NFO found for {item['imdb_id']} S{item['season']:02d}E{item['episode']:02d} in {season_dir}") - continue - - nfo_path = nfo_files[0] # Use first match - nfo_found_count += 1 - - elif media_type == 'movie': - # Handle movies - if not item_path.exists(): - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: Movie path doesn't exist: {item_path}") - continue - - # Find NFO file for this movie - nfo_files = list(item_path.glob("*.nfo")) - if not nfo_files: - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: No NFO found for movie {item['imdb_id']} in {item_path}") - continue - - nfo_path = nfo_files[0] # Use first match - nfo_found_count += 1 - else: - continue + # Transform the data to match the expected legacy format + episodes_missing = result['missing_items']['episodes'] + movies_missing = result['missing_items']['movies'] - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: Found NFO file: {nfo_path}") - - # Parse NFO and check for dateadded - try: - import xml.etree.ElementTree as ET - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find('dateadded') - - # Check if dateadded is missing or empty - has_dateadded = dateadded_elem is not None and dateadded_elem.text and dateadded_elem.text.strip() - - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: NFO {nfo_path} - dateadded element: {dateadded_elem}") - print(f"🔍 DEBUG: dateadded text: {dateadded_elem.text if dateadded_elem is not None else 'None'}") - print(f"🔍 DEBUG: has_dateadded: {has_dateadded}") - - # If NFO is missing dateadded, check if item should have a date - if not has_dateadded: - nfo_missing_dateadded_count += 1 - - # Item should have a date if it has one in the database, or if it has aired date - should_have_date = ( - item['dateadded'] is not None or - (media_type == 'episode' and item.get('aired')) or - media_type == 'movie' # Movies should generally have dateadded - ) - - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: should_have_date: {should_have_date} (db_date: {item['dateadded']}, aired: {item.get('aired')})") - - if should_have_date: - missing_dateadded.append({ - **item, - 'nfo_path': str(nfo_path), - 'nfo_exists': True, - 'dateadded_in_nfo': False, - 'should_have_date': True - }) - - if processed_count <= 5: # Debug first few - print(f"🔍 DEBUG: Added to missing list: {item['imdb_id']}") - - except ET.ParseError: - # NFO file is corrupted - missing_dateadded.append({ - **item, - 'nfo_path': str(nfo_path), - 'nfo_exists': True, - 'dateadded_in_nfo': False, - 'nfo_corrupt': True + # Combine for legacy items format + all_missing = [] + for episode in episodes_missing: + all_missing.append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": episode["series_name"], + "episode_name": episode["episode_name"], + "dateadded": episode["dateadded"], + "nfo_path": episode["nfo_path"], + "media_type": "episode", + "nfo_exists": True, + "dateadded_in_nfo": False, + "should_have_date": True }) - - except Exception as e: - if item['media_type'] == 'episode': - print(f"Error checking NFO for {item['imdb_id']} S{item['season']:02d}E{item['episode']:02d}: {e}") - else: - print(f"Error checking NFO for movie {item['imdb_id']}: {e}") - continue - - print(f"🔍 DEBUG: NFO scan complete!") - print(f"🔍 DEBUG: Total items: {len(all_items)}") - print(f"🔍 DEBUG: NFO files found: {nfo_found_count}") - print(f"🔍 DEBUG: NFO files missing dateadded: {nfo_missing_dateadded_count}") - print(f"🔍 DEBUG: Items that should have dateadded: {len(missing_dateadded)}") - - return { - "success": True, - "total_episodes_checked": len(episodes), - "total_movies_checked": len(movies), - "total_items_checked": len(all_items), - "missing_dateadded_count": len(missing_dateadded), - "items": missing_dateadded[:50] # Limit display to first 50 - } - + + for movie in movies_missing: + all_missing.append({ + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "dateadded": movie["dateadded"], + "nfo_path": movie["nfo_path"], + "media_type": "movie", + "nfo_exists": True, + "dateadded_in_nfo": False, + "should_have_date": True + }) + + return { + "success": True, + "total_episodes_checked": len(episodes_missing), # Close enough for UI + "total_movies_checked": len(movies_missing), + "total_items_checked": result['total_missing'], + "missing_dateadded_count": result['total_missing'], + "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'] + } + } + else: + print(f"❌ Core container scan failed: {response.status_code} - {response.text}") + return { + "success": False, + "error": f"Core container scan failed: {response.status_code}", + "message": f"Failed to check NFO files via core container: {response.status_code}" + } + except Exception as e: + print(f"❌ Error calling core container for NFO repair scan: {str(e)}") + import traceback + traceback.print_exc() return { "success": False, - "error": str(e), + "error": f"Core container communication failed: {str(e)}", "message": f"Failed to check NFO files: {str(e)}" } From f38ca539d6ae70e08798f26fa43817c05c067094 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:32:57 -0400 Subject: [PATCH 38/56] web: switch to aiohttp --- api/web_routes.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index f9e57e2..df119ea 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1545,16 +1545,17 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): print("🔧 Calling core container for NFO repair scan...") # Call the core container's NFO repair scan endpoint - import httpx + import aiohttp + import asyncio core_url = f"http://nfoguard-core:{config.core_api_port}/admin/nfo-repair-scan" print(f"🔍 DEBUG: Calling core container at: {core_url}") - async with httpx.AsyncClient(timeout=300.0) as client: # 5 minute timeout for filesystem scan - response = await client.get(core_url) - - if response.status_code == 200: - result = response.json() + timeout = aiohttp.ClientTimeout(total=300.0) # 5 minute timeout for filesystem scan + 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 scan complete: {result['total_missing']} items missing dateadded") # Transform the data to match the expected legacy format @@ -1604,13 +1605,14 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): "movies_missing": result['movies_missing'] } } - else: - print(f"❌ Core container scan failed: {response.status_code} - {response.text}") - return { - "success": False, - "error": f"Core container scan failed: {response.status_code}", - "message": f"Failed to check NFO files via core container: {response.status_code}" - } + else: + error_text = await response.text() + print(f"❌ Core container scan failed: {response.status} - {error_text}") + return { + "success": False, + "error": f"Core container scan failed: {response.status}", + "message": f"Failed to check NFO files via core container: {response.status}" + } except Exception as e: print(f"❌ Error calling core container for NFO repair scan: {str(e)}") From eba70ec0456acf6c2c8347ebbafa71db3040805c Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:35:03 -0400 Subject: [PATCH 39/56] web: more debugsy --- api/web_routes.py | 96 +++++++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index df119ea..92062f9 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1556,55 +1556,55 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): async with session.get(core_url) as response: if response.status == 200: result = await response.json() - print(f"✅ Core container scan complete: {result['total_missing']} items missing dateadded") - - # Transform the data to match the expected legacy format - episodes_missing = result['missing_items']['episodes'] - movies_missing = result['missing_items']['movies'] - - # Combine for legacy items format - all_missing = [] - for episode in episodes_missing: - all_missing.append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "series_name": episode["series_name"], - "episode_name": episode["episode_name"], - "dateadded": episode["dateadded"], - "nfo_path": episode["nfo_path"], - "media_type": "episode", - "nfo_exists": True, - "dateadded_in_nfo": False, - "should_have_date": True - }) - - for movie in movies_missing: - all_missing.append({ - "imdb_id": movie["imdb_id"], - "title": movie["title"], - "dateadded": movie["dateadded"], - "nfo_path": movie["nfo_path"], - "media_type": "movie", - "nfo_exists": True, - "dateadded_in_nfo": False, - "should_have_date": True - }) - - return { - "success": True, - "total_episodes_checked": len(episodes_missing), # Close enough for UI - "total_movies_checked": len(movies_missing), - "total_items_checked": result['total_missing'], - "missing_dateadded_count": result['total_missing'], - "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'] + print(f"✅ Core container scan complete: {result['total_missing']} items missing dateadded") + + # Transform the data to match the expected legacy format + episodes_missing = result['missing_items']['episodes'] + movies_missing = result['missing_items']['movies'] + + # Combine for legacy items format + all_missing = [] + for episode in episodes_missing: + all_missing.append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": episode["series_name"], + "episode_name": episode["episode_name"], + "dateadded": episode["dateadded"], + "nfo_path": episode["nfo_path"], + "media_type": "episode", + "nfo_exists": True, + "dateadded_in_nfo": False, + "should_have_date": True + }) + + for movie in movies_missing: + all_missing.append({ + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "dateadded": movie["dateadded"], + "nfo_path": movie["nfo_path"], + "media_type": "movie", + "nfo_exists": True, + "dateadded_in_nfo": False, + "should_have_date": True + }) + + return { + "success": True, + "total_episodes_checked": len(episodes_missing), # Close enough for UI + "total_movies_checked": len(movies_missing), + "total_items_checked": result['total_missing'], + "missing_dateadded_count": result['total_missing'], + "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'] + } } - } else: error_text = await response.text() print(f"❌ Core container scan failed: {response.status} - {error_text}") From 07cdbf1a29ec49f220eba250fbde66a6b7259178 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:37:49 -0400 Subject: [PATCH 40/56] web: web debugs --- api/routes.py | 239 +++++++++++++++++++++++++------------------------- 1 file changed, 121 insertions(+), 118 deletions(-) diff --git a/api/routes.py b/api/routes.py index 36de384..856a54e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2278,71 +2278,63 @@ def register_routes(app, dependencies: dict): # # Web interface available on separate container port 8081 @app.get("/admin/nfo-repair-scan") - async def nfo_repair_scan(dependencies: dict): - """Scan filesystem for episodes/movies missing dateadded elements in NFO files""" - from nfoguard.utils.db_utils import get_db_connection - import os - import xml.etree.ElementTree as ET + async def _nfo_repair_scan(): + return await nfo_repair_scan(dependencies) + +async def nfo_repair_scan(dependencies: dict): + """Scan filesystem for episodes/movies missing dateadded elements in NFO files""" + from nfoguard.utils.db_utils import get_db_connection + import os + import xml.etree.ElementTree as ET + + logger = dependencies.get("logger") + config = dependencies.get("config") + + if not logger or not config: + raise HTTPException(status_code=500, detail="Dependencies not available") + + logger.info("🔧 Starting NFO repair scan from core container") + + missing_items = { + "episodes": [], + "movies": [] + } + + try: + # Get database connection + db_conn = get_db_connection(config) + if not db_conn: + raise HTTPException(status_code=500, detail="Database connection failed") - logger = dependencies.get("logger") - config = dependencies.get("config") + # Scan episodes + logger.info("📺 Scanning episodes for missing dateadded elements") + episode_query = """ + SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded + FROM episodes + WHERE dateadded IS NOT NULL + ORDER BY imdb_id, season, episode + """ - if not logger or not config: - raise HTTPException(status_code=500, detail="Dependencies not available") + episodes = db_conn.fetch_all(episode_query) + logger.info(f"📊 Found {len(episodes)} episodes in database") - logger.info("🔧 Starting NFO repair scan from core container") - - missing_items = { - "episodes": [], - "movies": [] - } - - try: - # Get database connection - db_conn = get_db_connection(config) - if not db_conn: - raise HTTPException(status_code=500, detail="Database connection failed") + for episode in episodes: + # Build NFO path + nfo_path = os.path.join( + config.tv_library_path, + episode["series_name"], + f"Season {episode['season']:02d}", + f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" + ) - # Scan episodes - logger.info("📺 Scanning episodes for missing dateadded elements") - episode_query = """ - SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded - FROM episodes - WHERE dateadded IS NOT NULL - ORDER BY imdb_id, season, episode - """ - - episodes = db_conn.fetch_all(episode_query) - logger.info(f"📊 Found {len(episodes)} episodes in database") - - for episode in episodes: - # Build NFO path - nfo_path = os.path.join( - config.tv_library_path, - episode["series_name"], - f"Season {episode['season']:02d}", - f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" - ) - - # Check if NFO file exists and has dateadded - if os.path.exists(nfo_path): - try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") - - if dateadded_elem is None or not dateadded_elem.text: - missing_items["episodes"].append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "series_name": episode["series_name"], - "episode_name": episode["episode_name"], - "dateadded": episode["dateadded"], - "nfo_path": nfo_path - }) - except ET.ParseError as e: - logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + # Check if NFO file exists and has dateadded + if os.path.exists(nfo_path): + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + if dateadded_elem is None or not dateadded_elem.text: missing_items["episodes"].append({ "imdb_id": episode["imdb_id"], "season": episode["season"], @@ -2350,70 +2342,81 @@ def register_routes(app, dependencies: dict): "series_name": episode["series_name"], "episode_name": episode["episode_name"], "dateadded": episode["dateadded"], - "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" + "nfo_path": nfo_path }) + except ET.ParseError as e: + logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + missing_items["episodes"].append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": episode["series_name"], + "episode_name": episode["episode_name"], + "dateadded": episode["dateadded"], + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" + }) + + # Scan movies + logger.info("🎬 Scanning movies for missing dateadded elements") + movie_query = """ + SELECT DISTINCT imdb_id, title, dateadded + FROM movies + WHERE dateadded IS NOT NULL + ORDER BY title + """ + + movies = db_conn.fetch_all(movie_query) + logger.info(f"📊 Found {len(movies)} movies in database") + + for movie in movies: + # Build NFO path + nfo_path = os.path.join( + config.movie_library_path, + movie["title"], + f"{movie['title']}.nfo" + ) - # Scan movies - logger.info("🎬 Scanning movies for missing dateadded elements") - movie_query = """ - SELECT DISTINCT imdb_id, title, dateadded - FROM movies - WHERE dateadded IS NOT NULL - ORDER BY title - """ - - movies = db_conn.fetch_all(movie_query) - logger.info(f"📊 Found {len(movies)} movies in database") - - for movie in movies: - # Build NFO path - nfo_path = os.path.join( - config.movie_library_path, - movie["title"], - f"{movie['title']}.nfo" - ) - - # Check if NFO file exists and has dateadded - if os.path.exists(nfo_path): - try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") - - if dateadded_elem is None or not dateadded_elem.text: - missing_items["movies"].append({ - "imdb_id": movie["imdb_id"], - "title": movie["title"], - "dateadded": movie["dateadded"], - "nfo_path": nfo_path - }) - except ET.ParseError as e: - logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + # Check if NFO file exists and has dateadded + if os.path.exists(nfo_path): + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + if dateadded_elem is None or not dateadded_elem.text: missing_items["movies"].append({ "imdb_id": movie["imdb_id"], "title": movie["title"], "dateadded": movie["dateadded"], - "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" + "nfo_path": nfo_path }) - - db_conn.close() - - total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) - logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") - - return { - "status": "success", - "total_missing": total_missing, - "episodes_missing": len(missing_items["episodes"]), - "movies_missing": len(missing_items["movies"]), - "missing_items": missing_items - } - - except Exception as e: - logger.error(f"❌ Error during NFO repair scan: {str(e)}") - raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}") + except ET.ParseError as e: + logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + missing_items["movies"].append({ + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "dateadded": movie["dateadded"], + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" + }) + + db_conn.close() + + total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) + logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") + + return { + "status": "success", + "total_missing": total_missing, + "episodes_missing": len(missing_items["episodes"]), + "movies_missing": len(missing_items["movies"]), + "missing_items": missing_items + } + + except Exception as e: + logger.error(f"❌ Error during NFO repair scan: {str(e)}") + raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}") # --------------------------- # Core API - No Web Interface From 0b27fc1c6540376d27200f4d3fc8870b27310e6a Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:40:20 -0400 Subject: [PATCH 41/56] web: debug --- api/routes.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/api/routes.py b/api/routes.py index 856a54e..3857ba3 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2283,14 +2283,14 @@ def register_routes(app, dependencies: dict): async def nfo_repair_scan(dependencies: dict): """Scan filesystem for episodes/movies missing dateadded elements in NFO files""" - from nfoguard.utils.db_utils import get_db_connection import os import xml.etree.ElementTree as ET logger = dependencies.get("logger") config = dependencies.get("config") + db = dependencies.get("db") - if not logger or not config: + if not logger or not config or not db: raise HTTPException(status_code=500, detail="Dependencies not available") logger.info("🔧 Starting NFO repair scan from core container") @@ -2301,11 +2301,6 @@ async def nfo_repair_scan(dependencies: dict): } try: - # Get database connection - db_conn = get_db_connection(config) - if not db_conn: - raise HTTPException(status_code=500, detail="Database connection failed") - # Scan episodes logger.info("📺 Scanning episodes for missing dateadded elements") episode_query = """ @@ -2315,7 +2310,11 @@ async def nfo_repair_scan(dependencies: dict): ORDER BY imdb_id, season, episode """ - episodes = db_conn.fetch_all(episode_query) + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(episode_query) + episodes = [dict(row) for row in cursor.fetchall()] + logger.info(f"📊 Found {len(episodes)} episodes in database") for episode in episodes: @@ -2366,7 +2365,11 @@ async def nfo_repair_scan(dependencies: dict): ORDER BY title """ - movies = db_conn.fetch_all(movie_query) + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(movie_query) + movies = [dict(row) for row in cursor.fetchall()] + logger.info(f"📊 Found {len(movies)} movies in database") for movie in movies: @@ -2401,8 +2404,6 @@ async def nfo_repair_scan(dependencies: dict): "error": f"Parse error: {str(e)}" }) - db_conn.close() - total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") From 483bcc9d25dc50a8772e36a1f96c29b5d1ed92ad Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:43:12 -0400 Subject: [PATCH 42/56] web: more nfo scan debugs --- api/routes.py | 243 +++++++++++++++++++++++++------------------------- 1 file changed, 122 insertions(+), 121 deletions(-) diff --git a/api/routes.py b/api/routes.py index 3857ba3..e049bac 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2277,63 +2277,71 @@ def register_routes(app, dependencies: dict): # - Health checks (/health) # # Web interface available on separate container port 8081 - @app.get("/admin/nfo-repair-scan") - async def _nfo_repair_scan(): - return await nfo_repair_scan(dependencies) - -async def nfo_repair_scan(dependencies: dict): - """Scan filesystem for episodes/movies missing dateadded elements in NFO files""" - import os - import xml.etree.ElementTree as ET - logger = dependencies.get("logger") - config = dependencies.get("config") - db = dependencies.get("db") - - if not logger or not config or not db: - raise HTTPException(status_code=500, detail="Dependencies not available") - - logger.info("🔧 Starting NFO repair scan from core container") - - missing_items = { - "episodes": [], - "movies": [] - } - - try: - # Scan episodes - logger.info("📺 Scanning episodes for missing dateadded elements") - episode_query = """ - SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded - FROM episodes - WHERE dateadded IS NOT NULL - ORDER BY imdb_id, season, episode - """ + async def nfo_repair_scan(): + """Scan filesystem for episodes/movies missing dateadded elements in NFO files""" + import os + import xml.etree.ElementTree as ET - with db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(episode_query) - episodes = [dict(row) for row in cursor.fetchall()] - - logger.info(f"📊 Found {len(episodes)} episodes in database") + logger = dependencies.get("logger") + config = dependencies.get("config") + db = dependencies.get("db") - for episode in episodes: - # Build NFO path - nfo_path = os.path.join( - config.tv_library_path, - episode["series_name"], - f"Season {episode['season']:02d}", - f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" - ) + if not logger or not config or not db: + raise HTTPException(status_code=500, detail="Dependencies not available") + + logger.info("🔧 Starting NFO repair scan from core container") + + missing_items = { + "episodes": [], + "movies": [] + } + + try: + # Scan episodes + logger.info("📺 Scanning episodes for missing dateadded elements") + episode_query = """ + SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded + FROM episodes + WHERE dateadded IS NOT NULL + ORDER BY imdb_id, season, episode + """ - # Check if NFO file exists and has dateadded - if os.path.exists(nfo_path): - try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") - - if dateadded_elem is None or not dateadded_elem.text: + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(episode_query) + episodes = [dict(row) for row in cursor.fetchall()] + + logger.info(f"📊 Found {len(episodes)} episodes in database") + + for episode in episodes: + # Build NFO path + nfo_path = os.path.join( + config.tv_library_path, + episode["series_name"], + f"Season {episode['season']:02d}", + f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" + ) + + # Check if NFO file exists and has dateadded + if os.path.exists(nfo_path): + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + if dateadded_elem is None or not dateadded_elem.text: + missing_items["episodes"].append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": episode["series_name"], + "episode_name": episode["episode_name"], + "dateadded": episode["dateadded"], + "nfo_path": nfo_path + }) + except ET.ParseError as e: + logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") missing_items["episodes"].append({ "imdb_id": episode["imdb_id"], "season": episode["season"], @@ -2341,83 +2349,76 @@ async def nfo_repair_scan(dependencies: dict): "series_name": episode["series_name"], "episode_name": episode["episode_name"], "dateadded": episode["dateadded"], - "nfo_path": nfo_path + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" }) - except ET.ParseError as e: - logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") - missing_items["episodes"].append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "series_name": episode["series_name"], - "episode_name": episode["episode_name"], - "dateadded": episode["dateadded"], - "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" - }) - # Scan movies - logger.info("🎬 Scanning movies for missing dateadded elements") - movie_query = """ - SELECT DISTINCT imdb_id, title, dateadded - FROM movies - WHERE dateadded IS NOT NULL - ORDER BY title - """ - - with db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(movie_query) - movies = [dict(row) for row in cursor.fetchall()] + # Scan movies + logger.info("🎬 Scanning movies for missing dateadded elements") + movie_query = """ + SELECT DISTINCT imdb_id, title, dateadded + FROM movies + WHERE dateadded IS NOT NULL + ORDER BY title + """ - logger.info(f"📊 Found {len(movies)} movies in database") + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(movie_query) + movies = [dict(row) for row in cursor.fetchall()] + + logger.info(f"📊 Found {len(movies)} movies in database") - for movie in movies: - # Build NFO path - nfo_path = os.path.join( - config.movie_library_path, - movie["title"], - f"{movie['title']}.nfo" - ) - - # Check if NFO file exists and has dateadded - if os.path.exists(nfo_path): - try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") - - if dateadded_elem is None or not dateadded_elem.text: + for movie in movies: + # Build NFO path + nfo_path = os.path.join( + config.movie_library_path, + movie["title"], + f"{movie['title']}.nfo" + ) + + # Check if NFO file exists and has dateadded + if os.path.exists(nfo_path): + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + if dateadded_elem is None or not dateadded_elem.text: + missing_items["movies"].append({ + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "dateadded": movie["dateadded"], + "nfo_path": nfo_path + }) + except ET.ParseError as e: + logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") missing_items["movies"].append({ "imdb_id": movie["imdb_id"], "title": movie["title"], "dateadded": movie["dateadded"], - "nfo_path": nfo_path + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" }) - except ET.ParseError as e: - logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") - missing_items["movies"].append({ - "imdb_id": movie["imdb_id"], - "title": movie["title"], - "dateadded": movie["dateadded"], - "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" - }) - - total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) - logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") - - return { - "status": "success", - "total_missing": total_missing, - "episodes_missing": len(missing_items["episodes"]), - "movies_missing": len(missing_items["movies"]), - "missing_items": missing_items - } - - except Exception as e: - logger.error(f"❌ Error during NFO repair scan: {str(e)}") - raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}") + + total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) + logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") + + return { + "status": "success", + "total_missing": total_missing, + "episodes_missing": len(missing_items["episodes"]), + "movies_missing": len(missing_items["movies"]), + "missing_items": missing_items + } + + except Exception as e: + logger.error(f"❌ Error during NFO repair scan: {str(e)}") + raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}") + + @app.get("/admin/nfo-repair-scan") + async def _nfo_repair_scan(): + return await nfo_repair_scan() # --------------------------- # Core API - No Web Interface From 24f05079f322609800edce9c0485b7d09fb90034 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:46:22 -0400 Subject: [PATCH 43/56] web: change url from nfoguard-core to nfoguard --- api/web_routes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/web_routes.py b/api/web_routes.py index 92062f9..df4720b 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -619,7 +619,7 @@ async def update_episode_date(dependencies: dict, imdb_id: str, season: int, epi import os # Get core container connection details - core_host = os.environ.get("CORE_API_HOST", "nfoguard-core") + core_host = os.environ.get("CORE_API_HOST", "nfoguard") core_port = os.environ.get("CORE_API_PORT", "8080") # Call core container to update NFO file @@ -1412,7 +1412,7 @@ def register_web_routes(app, dependencies): import socket # Get core container URL - core_host = os.environ.get("CORE_API_HOST", "nfoguard-core") + core_host = os.environ.get("CORE_API_HOST", "nfoguard") core_port = os.environ.get("CORE_API_PORT", "8080") # Forward query parameters from the request @@ -1460,7 +1460,7 @@ def register_web_routes(app, dependencies): import socket # Get core container connection details - core_host = os.environ.get("CORE_API_HOST", "nfoguard-core") + core_host = os.environ.get("CORE_API_HOST", "nfoguard") core_port = os.environ.get("CORE_API_PORT", "8080") try: @@ -1548,7 +1548,7 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): import aiohttp import asyncio - core_url = f"http://nfoguard-core:{config.core_api_port}/admin/nfo-repair-scan" + core_url = f"http://nfoguard:{config.core_api_port}/admin/nfo-repair-scan" print(f"🔍 DEBUG: Calling core container at: {core_url}") timeout = aiohttp.ClientTimeout(total=300.0) # 5 minute timeout for filesystem scan From 4cb9250c3a448abc5c71303241fcb4aea88f3bae Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:49:36 -0400 Subject: [PATCH 44/56] web: debug --- api/routes.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index e049bac..edc4fca 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2283,12 +2283,20 @@ def register_routes(app, dependencies: dict): import os import xml.etree.ElementTree as ET + # Debug: Check what dependencies are available + print(f"🔍 DEBUG: dependencies keys: {list(dependencies.keys()) if dependencies else 'None'}") + print(f"🔍 DEBUG: dependencies type: {type(dependencies)}") + logger = dependencies.get("logger") config = dependencies.get("config") db = dependencies.get("db") + print(f"🔍 DEBUG: logger: {logger is not None}") + print(f"🔍 DEBUG: config: {config is not None}") + print(f"🔍 DEBUG: db: {db is not None}") + if not logger or not config or not db: - raise HTTPException(status_code=500, detail="Dependencies not available") + raise HTTPException(status_code=500, detail=f"Dependencies not available - logger:{logger is not None}, config:{config is not None}, db:{db is not None}") logger.info("🔧 Starting NFO repair scan from core container") From d4f4bcf7221a3ce7b89a93ee3a6635bc38e681de Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 12:55:00 -0400 Subject: [PATCH 45/56] web: debugging --- api/routes.py | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/api/routes.py b/api/routes.py index edc4fca..06487d1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2283,22 +2283,13 @@ def register_routes(app, dependencies: dict): import os import xml.etree.ElementTree as ET - # Debug: Check what dependencies are available - print(f"🔍 DEBUG: dependencies keys: {list(dependencies.keys()) if dependencies else 'None'}") - print(f"🔍 DEBUG: dependencies type: {type(dependencies)}") - - logger = dependencies.get("logger") config = dependencies.get("config") db = dependencies.get("db") - print(f"🔍 DEBUG: logger: {logger is not None}") - print(f"🔍 DEBUG: config: {config is not None}") - print(f"🔍 DEBUG: db: {db is not None}") + if not config or not db: + raise HTTPException(status_code=500, detail=f"Dependencies not available - config:{config is not None}, db:{db is not None}") - if not logger or not config or not db: - raise HTTPException(status_code=500, detail=f"Dependencies not available - logger:{logger is not None}, config:{config is not None}, db:{db is not None}") - - logger.info("🔧 Starting NFO repair scan from core container") + print("🔧 Starting NFO repair scan from core container") missing_items = { "episodes": [], @@ -2307,7 +2298,7 @@ def register_routes(app, dependencies: dict): try: # Scan episodes - logger.info("📺 Scanning episodes for missing dateadded elements") + print("📺 Scanning episodes for missing dateadded elements") episode_query = """ SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded FROM episodes @@ -2320,7 +2311,7 @@ def register_routes(app, dependencies: dict): cursor.execute(episode_query) episodes = [dict(row) for row in cursor.fetchall()] - logger.info(f"📊 Found {len(episodes)} episodes in database") + print(f"📊 Found {len(episodes)} episodes in database") for episode in episodes: # Build NFO path @@ -2349,7 +2340,7 @@ def register_routes(app, dependencies: dict): "nfo_path": nfo_path }) except ET.ParseError as e: - logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") missing_items["episodes"].append({ "imdb_id": episode["imdb_id"], "season": episode["season"], @@ -2362,7 +2353,7 @@ def register_routes(app, dependencies: dict): }) # Scan movies - logger.info("🎬 Scanning movies for missing dateadded elements") + print("🎬 Scanning movies for missing dateadded elements") movie_query = """ SELECT DISTINCT imdb_id, title, dateadded FROM movies @@ -2375,7 +2366,7 @@ def register_routes(app, dependencies: dict): cursor.execute(movie_query) movies = [dict(row) for row in cursor.fetchall()] - logger.info(f"📊 Found {len(movies)} movies in database") + print(f"📊 Found {len(movies)} movies in database") for movie in movies: # Build NFO path @@ -2400,7 +2391,7 @@ def register_routes(app, dependencies: dict): "nfo_path": nfo_path }) except ET.ParseError as e: - logger.warning(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") missing_items["movies"].append({ "imdb_id": movie["imdb_id"], "title": movie["title"], @@ -2410,7 +2401,7 @@ def register_routes(app, dependencies: dict): }) total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) - logger.info(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") + print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") return { "status": "success", @@ -2421,7 +2412,7 @@ def register_routes(app, dependencies: dict): } except Exception as e: - logger.error(f"❌ Error during NFO repair scan: {str(e)}") + print(f"❌ Error during NFO repair scan: {str(e)}") raise HTTPException(status_code=500, detail=f"NFO repair scan failed: {str(e)}") @app.get("/admin/nfo-repair-scan") From 6b9e0b43f89afd3b9fb98b1f74fb6d2f760a733c Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:04:55 -0400 Subject: [PATCH 46/56] web: nfo refresh debug --- api/routes.py | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/api/routes.py b/api/routes.py index 06487d1..ca3d6ea 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2300,10 +2300,11 @@ def register_routes(app, dependencies: dict): # Scan episodes print("📺 Scanning episodes for missing dateadded elements") episode_query = """ - SELECT DISTINCT imdb_id, season, episode, series_name, episode_name, dateadded - FROM episodes - WHERE dateadded IS NOT NULL - ORDER BY imdb_id, season, episode + SELECT DISTINCT e.imdb_id, e.season, e.episode, s.path as series_path, e.dateadded + FROM episodes e + JOIN series s ON e.imdb_id = s.imdb_id + WHERE e.dateadded IS NOT NULL + ORDER BY e.imdb_id, e.season, e.episode """ with db.get_connection() as conn: @@ -2314,10 +2315,10 @@ def register_routes(app, dependencies: dict): print(f"📊 Found {len(episodes)} episodes in database") for episode in episodes: - # Build NFO path + # Build NFO path using series path + series_name = os.path.basename(episode["series_path"]) nfo_path = os.path.join( - config.tv_library_path, - episode["series_name"], + episode["series_path"], f"Season {episode['season']:02d}", f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" ) @@ -2334,8 +2335,8 @@ def register_routes(app, dependencies: dict): "imdb_id": episode["imdb_id"], "season": episode["season"], "episode": episode["episode"], - "series_name": episode["series_name"], - "episode_name": episode["episode_name"], + "series_name": series_name, + "series_path": episode["series_path"], "dateadded": episode["dateadded"], "nfo_path": nfo_path }) @@ -2345,8 +2346,8 @@ def register_routes(app, dependencies: dict): "imdb_id": episode["imdb_id"], "season": episode["season"], "episode": episode["episode"], - "series_name": episode["series_name"], - "episode_name": episode["episode_name"], + "series_name": series_name, + "series_path": episode["series_path"], "dateadded": episode["dateadded"], "nfo_path": nfo_path, "error": f"Parse error: {str(e)}" @@ -2355,10 +2356,10 @@ def register_routes(app, dependencies: dict): # Scan movies print("🎬 Scanning movies for missing dateadded elements") movie_query = """ - SELECT DISTINCT imdb_id, title, dateadded + SELECT DISTINCT imdb_id, path, dateadded FROM movies WHERE dateadded IS NOT NULL - ORDER BY title + ORDER BY path """ with db.get_connection() as conn: @@ -2369,11 +2370,11 @@ def register_routes(app, dependencies: dict): print(f"📊 Found {len(movies)} movies in database") for movie in movies: - # Build NFO path + # Build NFO path using movie path + movie_title = os.path.basename(movie["path"]) nfo_path = os.path.join( - config.movie_library_path, - movie["title"], - f"{movie['title']}.nfo" + movie["path"], + f"{movie_title}.nfo" ) # Check if NFO file exists and has dateadded @@ -2386,7 +2387,8 @@ def register_routes(app, dependencies: dict): if dateadded_elem is None or not dateadded_elem.text: missing_items["movies"].append({ "imdb_id": movie["imdb_id"], - "title": movie["title"], + "title": movie_title, + "path": movie["path"], "dateadded": movie["dateadded"], "nfo_path": nfo_path }) @@ -2394,7 +2396,8 @@ def register_routes(app, dependencies: dict): print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") missing_items["movies"].append({ "imdb_id": movie["imdb_id"], - "title": movie["title"], + "title": movie_title, + "path": movie["path"], "dateadded": movie["dateadded"], "nfo_path": nfo_path, "error": f"Parse error: {str(e)}" From 63ab35b24c379771a21358286328544edbf41f3f Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:09:43 -0400 Subject: [PATCH 47/56] updatre: web scans --- api/routes.py | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/api/routes.py b/api/routes.py index ca3d6ea..37984e9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2300,10 +2300,10 @@ def register_routes(app, dependencies: dict): # Scan episodes print("📺 Scanning episodes for missing dateadded elements") episode_query = """ - SELECT DISTINCT e.imdb_id, e.season, e.episode, s.path as series_path, e.dateadded + SELECT DISTINCT e.imdb_id, e.season, e.episode, s.path as series_path, e.dateadded, e.has_video_file FROM episodes e JOIN series s ON e.imdb_id = s.imdb_id - WHERE e.dateadded IS NOT NULL + WHERE e.has_video_file = TRUE ORDER BY e.imdb_id, e.season, e.episode """ @@ -2330,7 +2330,8 @@ def register_routes(app, dependencies: dict): root = tree.getroot() dateadded_elem = root.find("dateadded") - if dateadded_elem is None or not dateadded_elem.text: + # NFO file is missing dateadded element + if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): missing_items["episodes"].append({ "imdb_id": episode["imdb_id"], "season": episode["season"], @@ -2338,7 +2339,8 @@ def register_routes(app, dependencies: dict): "series_name": series_name, "series_path": episode["series_path"], "dateadded": episode["dateadded"], - "nfo_path": nfo_path + "nfo_path": nfo_path, + "reason": "NFO missing dateadded element" }) except ET.ParseError as e: print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") @@ -2352,13 +2354,26 @@ def register_routes(app, dependencies: dict): "nfo_path": nfo_path, "error": f"Parse error: {str(e)}" }) + else: + # NFO file doesn't exist at all + if episode["has_video_file"]: + missing_items["episodes"].append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": series_name, + "series_path": episode["series_path"], + "dateadded": episode["dateadded"], + "nfo_path": nfo_path, + "reason": "NFO file does not exist" + }) # Scan movies print("🎬 Scanning movies for missing dateadded elements") movie_query = """ - SELECT DISTINCT imdb_id, path, dateadded + SELECT DISTINCT imdb_id, path, dateadded, has_video_file FROM movies - WHERE dateadded IS NOT NULL + WHERE has_video_file = TRUE ORDER BY path """ @@ -2384,13 +2399,15 @@ def register_routes(app, dependencies: dict): root = tree.getroot() dateadded_elem = root.find("dateadded") - if dateadded_elem is None or not dateadded_elem.text: + # NFO file is missing dateadded element + if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): missing_items["movies"].append({ "imdb_id": movie["imdb_id"], "title": movie_title, "path": movie["path"], "dateadded": movie["dateadded"], - "nfo_path": nfo_path + "nfo_path": nfo_path, + "reason": "NFO missing dateadded element" }) except ET.ParseError as e: print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") @@ -2402,6 +2419,17 @@ def register_routes(app, dependencies: dict): "nfo_path": nfo_path, "error": f"Parse error: {str(e)}" }) + else: + # NFO file doesn't exist at all + if movie["has_video_file"]: + missing_items["movies"].append({ + "imdb_id": movie["imdb_id"], + "title": movie_title, + "path": movie["path"], + "dateadded": movie["dateadded"], + "nfo_path": nfo_path, + "reason": "NFO file does not exist" + }) total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") From a68f7ee4bb8cfc4264e6ee97fd981d0643ba5d3f Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:16:05 -0400 Subject: [PATCH 48/56] updates: web scan --- api/routes.py | 178 +++++++++++++++++++++++++++------------------- api/web_routes.py | 3 +- 2 files changed, 105 insertions(+), 76 deletions(-) diff --git a/api/routes.py b/api/routes.py index 37984e9..72ac1b7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2317,33 +2317,49 @@ def register_routes(app, dependencies: dict): for episode in episodes: # Build NFO path using series path series_name = os.path.basename(episode["series_path"]) - nfo_path = os.path.join( - episode["series_path"], - f"Season {episode['season']:02d}", - f"S{episode['season']:02d}E{episode['episode']:02d}.nfo" - ) - # Check if NFO file exists and has dateadded - if os.path.exists(nfo_path): - try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") - - # NFO file is missing dateadded element - if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): - missing_items["episodes"].append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "series_name": series_name, - "series_path": episode["series_path"], - "dateadded": episode["dateadded"], - "nfo_path": nfo_path, - "reason": "NFO missing dateadded element" - }) - except ET.ParseError as e: - print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + # Try multiple NFO file naming patterns that might exist + season_dir = os.path.join(episode["series_path"], f"Season {episode['season']:02d}") + + # Handle Season 0 (Specials) naming + if episode['season'] == 0: + season_dir = os.path.join(episode["series_path"], "Specials") + + nfo_patterns = [ + f"S{episode['season']:02d}E{episode['episode']:02d}.nfo", + f"{series_name}-S{episode['season']:02d}E{episode['episode']:02d}*.nfo" + ] + + nfo_path = None + # Find the actual NFO file that exists + if os.path.exists(season_dir): + for pattern in nfo_patterns: + if '*' in pattern: + # Use glob for wildcard patterns + import glob + matches = glob.glob(os.path.join(season_dir, pattern)) + if matches: + nfo_path = matches[0] # Use first match + break + else: + # Direct file check + test_path = os.path.join(season_dir, pattern) + if os.path.exists(test_path): + nfo_path = test_path + break + + # Skip if no NFO file found (already checked above) + if not nfo_path: + continue + + # Check if NFO file has dateadded element + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + # NFO file is missing dateadded element + if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): missing_items["episodes"].append({ "imdb_id": episode["imdb_id"], "season": episode["season"], @@ -2352,21 +2368,20 @@ def register_routes(app, dependencies: dict): "series_path": episode["series_path"], "dateadded": episode["dateadded"], "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" - }) - else: - # NFO file doesn't exist at all - if episode["has_video_file"]: - missing_items["episodes"].append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "series_name": series_name, - "series_path": episode["series_path"], - "dateadded": episode["dateadded"], - "nfo_path": nfo_path, - "reason": "NFO file does not exist" + "reason": "NFO missing dateadded element" }) + except ET.ParseError as e: + print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + missing_items["episodes"].append({ + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "series_name": series_name, + "series_path": episode["series_path"], + "dateadded": episode["dateadded"], + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" + }) # Scan movies print("🎬 Scanning movies for missing dateadded elements") @@ -2387,49 +2402,62 @@ def register_routes(app, dependencies: dict): for movie in movies: # Build NFO path using movie path movie_title = os.path.basename(movie["path"]) - nfo_path = os.path.join( - movie["path"], - f"{movie_title}.nfo" - ) - # Check if NFO file exists and has dateadded - if os.path.exists(nfo_path): - try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") - - # NFO file is missing dateadded element - if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): - missing_items["movies"].append({ - "imdb_id": movie["imdb_id"], - "title": movie_title, - "path": movie["path"], - "dateadded": movie["dateadded"], - "nfo_path": nfo_path, - "reason": "NFO missing dateadded element" - }) - except ET.ParseError as e: - print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + # Try multiple NFO file naming patterns that might exist + nfo_patterns = [ + f"{movie_title}.nfo", + "movie.nfo", + f"{movie_title}*.nfo" + ] + + nfo_path = None + # Find the actual NFO file that exists + if os.path.exists(movie["path"]): + for pattern in nfo_patterns: + if '*' in pattern: + # Use glob for wildcard patterns + import glob + matches = glob.glob(os.path.join(movie["path"], pattern)) + if matches: + nfo_path = matches[0] # Use first match + break + else: + # Direct file check + test_path = os.path.join(movie["path"], pattern) + if os.path.exists(test_path): + nfo_path = test_path + break + + # Skip if no NFO file found + if not nfo_path: + continue + + # Check if NFO file has dateadded element + try: + tree = ET.parse(nfo_path) + root = tree.getroot() + dateadded_elem = root.find("dateadded") + + # NFO file is missing dateadded element + if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): missing_items["movies"].append({ "imdb_id": movie["imdb_id"], "title": movie_title, "path": movie["path"], "dateadded": movie["dateadded"], "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" - }) - else: - # NFO file doesn't exist at all - if movie["has_video_file"]: - missing_items["movies"].append({ - "imdb_id": movie["imdb_id"], - "title": movie_title, - "path": movie["path"], - "dateadded": movie["dateadded"], - "nfo_path": nfo_path, - "reason": "NFO file does not exist" + "reason": "NFO missing dateadded element" }) + except ET.ParseError as e: + print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") + missing_items["movies"].append({ + "imdb_id": movie["imdb_id"], + "title": movie_title, + "path": movie["path"], + "dateadded": movie["dateadded"], + "nfo_path": nfo_path, + "error": f"Parse error: {str(e)}" + }) total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") diff --git a/api/web_routes.py b/api/web_routes.py index df4720b..e18cae8 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1570,9 +1570,10 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): "season": episode["season"], "episode": episode["episode"], "series_name": episode["series_name"], - "episode_name": episode["episode_name"], + "series_path": episode.get("series_path"), "dateadded": episode["dateadded"], "nfo_path": episode["nfo_path"], + "reason": episode.get("reason", "NFO missing dateadded element"), "media_type": "episode", "nfo_exists": True, "dateadded_in_nfo": False, From 14ae2a205ca77934509c2259ba57c3a25a9d67d4 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:22:11 -0400 Subject: [PATCH 49/56] updates: debugs --- api/routes.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/api/routes.py b/api/routes.py index 72ac1b7..38cdbfa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2291,6 +2291,9 @@ def register_routes(app, dependencies: dict): print("🔧 Starting NFO repair scan from core container") + import time + scan_start_time = time.time() + missing_items = { "episodes": [], "movies": [] @@ -2313,8 +2316,25 @@ def register_routes(app, dependencies: dict): episodes = [dict(row) for row in cursor.fetchall()] print(f"📊 Found {len(episodes)} episodes in database") - + + processed_count = 0 for episode in episodes: + processed_count += 1 + + # Progress logging every 1000 items + if processed_count % 1000 == 0: + elapsed = time.time() - scan_start_time + print(f"📊 Progress: {processed_count}/{len(episodes)} episodes processed in {elapsed:.1f}s, {len(missing_items['episodes'])} missing found so far") + + # Time-based exit (max 3 minutes for episodes) + if time.time() - scan_start_time > 180: + print(f"⏰ Episode scan timeout after 3 minutes - processed {processed_count}/{len(episodes)} episodes") + break + + # Early exit if we find too many missing items (performance safety) + if len(missing_items['episodes']) > 1000: + print(f"⚠️ Found {len(missing_items['episodes'])} missing episodes - stopping scan to prevent performance issues") + break # Build NFO path using series path series_name = os.path.basename(episode["series_path"]) @@ -2398,8 +2418,25 @@ def register_routes(app, dependencies: dict): movies = [dict(row) for row in cursor.fetchall()] print(f"📊 Found {len(movies)} movies in database") - + + processed_count = 0 for movie in movies: + processed_count += 1 + + # Progress logging every 500 items + if processed_count % 500 == 0: + elapsed = time.time() - scan_start_time + print(f"📊 Progress: {processed_count}/{len(movies)} movies processed in {elapsed:.1f}s, {len(missing_items['movies'])} missing found so far") + + # Time-based exit (max 5 minutes total) + if time.time() - scan_start_time > 300: + print(f"⏰ Total scan timeout after 5 minutes - processed {processed_count}/{len(movies)} movies") + break + + # Early exit if we find too many missing items (performance safety) + if len(missing_items['movies']) > 500: + print(f"⚠️ Found {len(missing_items['movies'])} missing movies - stopping scan to prevent performance issues") + break # Build NFO path using movie path movie_title = os.path.basename(movie["path"]) From 9c8da4d63b031e44b9524edda08b655bd80818ac Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:29:03 -0400 Subject: [PATCH 50/56] updates: debugs for 35 without date --- api/routes.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 38cdbfa..6a0c98d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2303,7 +2303,7 @@ def register_routes(app, dependencies: dict): # Scan episodes print("📺 Scanning episodes for missing dateadded elements") episode_query = """ - SELECT DISTINCT e.imdb_id, e.season, e.episode, s.path as series_path, e.dateadded, e.has_video_file + SELECT DISTINCT e.imdb_id, e.season, e.episode, s.path as series_path, e.dateadded, e.has_video_file, e.source FROM episodes e JOIN series s ON e.imdb_id = s.imdb_id WHERE e.has_video_file = TRUE @@ -2338,6 +2338,19 @@ def register_routes(app, dependencies: dict): # Build NFO path using series path series_name = os.path.basename(episode["series_path"]) + # Debug specific episodes we know should be missing + if "Hudson" in series_name and episode['season'] == 8 and episode['episode'] == 5: + print(f"🔍 DEBUG: Processing Hudson & Rex S08E05") + print(f" Series path: {episode['series_path']}") + print(f" DB dateadded: {episode['dateadded']}") + print(f" DB source: {episode.get('source', 'unknown')}") + + if "Star Trek" in series_name and episode['season'] == 3 and episode['episode'] == 7: + print(f"🔍 DEBUG: Processing Star Trek SNW S03E07") + print(f" Series path: {episode['series_path']}") + print(f" DB dateadded: {episode['dateadded']}") + print(f" DB source: {episode.get('source', 'unknown')}") + # Try multiple NFO file naming patterns that might exist season_dir = os.path.join(episode["series_path"], f"Season {episode['season']:02d}") @@ -2370,6 +2383,12 @@ def register_routes(app, dependencies: dict): # Skip if no NFO file found (already checked above) if not nfo_path: + # Debug specific episodes + if "Hudson" in series_name and episode['season'] == 8 and episode['episode'] == 5: + print(f"🔍 DEBUG: Hudson & Rex S08E05 - NFO file not found at expected paths") + print(f" Series path: {episode['series_path']}") + print(f" Season dir: {season_dir}") + print(f" DB source: {episode.get('source', 'unknown')}") continue # Check if NFO file has dateadded element @@ -2380,6 +2399,13 @@ def register_routes(app, dependencies: dict): # NFO file is missing dateadded element if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): + # Special logging for database sync issues + db_source = episode.get("source", "unknown") + if db_source == "Nfo_File_Existing": + print(f"🔍 DATABASE SYNC ISSUE: {series_name} S{episode['season']:02d}E{episode['episode']:02d} - DB says 'Nfo_File_Existing' but NFO missing dateadded") + print(f" NFO Path: {nfo_path}") + print(f" DB dateadded: {episode['dateadded']}") + missing_items["episodes"].append({ "imdb_id": episode["imdb_id"], "season": episode["season"], @@ -2388,6 +2414,7 @@ def register_routes(app, dependencies: dict): "series_path": episode["series_path"], "dateadded": episode["dateadded"], "nfo_path": nfo_path, + "db_source": db_source, "reason": "NFO missing dateadded element" }) except ET.ParseError as e: From d50ac88237311713b938dd7abc98addd737df405 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:36:59 -0400 Subject: [PATCH 51/56] web: change to a grep for missing nfo --- api/routes.py | 380 ++++++++++++++++++++++---------------------------- 1 file changed, 169 insertions(+), 211 deletions(-) diff --git a/api/routes.py b/api/routes.py index 6a0c98d..186155a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2300,228 +2300,186 @@ def register_routes(app, dependencies: dict): } try: - # Scan episodes - print("📺 Scanning episodes for missing dateadded elements") - episode_query = """ - SELECT DISTINCT e.imdb_id, e.season, e.episode, s.path as series_path, e.dateadded, e.has_video_file, e.source - FROM episodes e - JOIN series s ON e.imdb_id = s.imdb_id - WHERE e.has_video_file = TRUE - ORDER BY e.imdb_id, e.season, e.episode - """ + # Use filesystem-first approach: find all NFO files missing dateadded + print("📺 Scanning NFO files directly for missing dateadded elements") - with db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(episode_query) - episodes = [dict(row) for row in cursor.fetchall()] - - print(f"📊 Found {len(episodes)} episodes in database") + # Get TV and Movie library paths from config + tv_path = getattr(config, 'tv_library_path', '/media/TV') + movie_path = getattr(config, 'movie_library_path', '/media/Movies') - processed_count = 0 - for episode in episodes: - processed_count += 1 - - # Progress logging every 1000 items - if processed_count % 1000 == 0: - elapsed = time.time() - scan_start_time - print(f"📊 Progress: {processed_count}/{len(episodes)} episodes processed in {elapsed:.1f}s, {len(missing_items['episodes'])} missing found so far") - - # Time-based exit (max 3 minutes for episodes) - if time.time() - scan_start_time > 180: - print(f"⏰ Episode scan timeout after 3 minutes - processed {processed_count}/{len(episodes)} episodes") - break - - # Early exit if we find too many missing items (performance safety) - if len(missing_items['episodes']) > 1000: - print(f"⚠️ Found {len(missing_items['episodes'])} missing episodes - stopping scan to prevent performance issues") - break - # Build NFO path using series path - series_name = os.path.basename(episode["series_path"]) - - # Debug specific episodes we know should be missing - if "Hudson" in series_name and episode['season'] == 8 and episode['episode'] == 5: - print(f"🔍 DEBUG: Processing Hudson & Rex S08E05") - print(f" Series path: {episode['series_path']}") - print(f" DB dateadded: {episode['dateadded']}") - print(f" DB source: {episode.get('source', 'unknown')}") - - if "Star Trek" in series_name and episode['season'] == 3 and episode['episode'] == 7: - print(f"🔍 DEBUG: Processing Star Trek SNW S03E07") - print(f" Series path: {episode['series_path']}") - print(f" DB dateadded: {episode['dateadded']}") - print(f" DB source: {episode.get('source', 'unknown')}") - - # Try multiple NFO file naming patterns that might exist - season_dir = os.path.join(episode["series_path"], f"Season {episode['season']:02d}") - - # Handle Season 0 (Specials) naming - if episode['season'] == 0: - season_dir = os.path.join(episode["series_path"], "Specials") - - nfo_patterns = [ - f"S{episode['season']:02d}E{episode['episode']:02d}.nfo", - f"{series_name}-S{episode['season']:02d}E{episode['episode']:02d}*.nfo" - ] - - nfo_path = None - # Find the actual NFO file that exists - if os.path.exists(season_dir): - for pattern in nfo_patterns: - if '*' in pattern: - # Use glob for wildcard patterns - import glob - matches = glob.glob(os.path.join(season_dir, pattern)) - if matches: - nfo_path = matches[0] # Use first match - break - else: - # Direct file check - test_path = os.path.join(season_dir, pattern) - if os.path.exists(test_path): - nfo_path = test_path - break - - # Skip if no NFO file found (already checked above) - if not nfo_path: - # Debug specific episodes - if "Hudson" in series_name and episode['season'] == 8 and episode['episode'] == 5: - print(f"🔍 DEBUG: Hudson & Rex S08E05 - NFO file not found at expected paths") - print(f" Series path: {episode['series_path']}") - print(f" Season dir: {season_dir}") - print(f" DB source: {episode.get('source', 'unknown')}") - continue - - # Check if NFO file has dateadded element + print(f"📁 Scanning TV path: {tv_path}") + print(f"📁 Scanning Movie path: {movie_path}") + + import subprocess + import re + + # Find all NFO files and check which ones are missing dateadded + # This is much faster than database-driven approach + missing_nfo_files = [] + + # Find all NFO files in TV directory + if os.path.exists(tv_path): + print("🔍 Finding all TV NFO files...") try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") + # Use find to get all NFO files + find_result = subprocess.run( + ['find', tv_path, '-name', '*.nfo', '-type', 'f'], + capture_output=True, text=True, timeout=60 + ) - # NFO file is missing dateadded element - if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): - # Special logging for database sync issues - db_source = episode.get("source", "unknown") - if db_source == "Nfo_File_Existing": - print(f"🔍 DATABASE SYNC ISSUE: {series_name} S{episode['season']:02d}E{episode['episode']:02d} - DB says 'Nfo_File_Existing' but NFO missing dateadded") - print(f" NFO Path: {nfo_path}") - print(f" DB dateadded: {episode['dateadded']}") + if find_result.returncode == 0: + tv_nfo_files = find_result.stdout.strip().split('\n') + tv_nfo_files = [f for f in tv_nfo_files if f] # Remove empty strings + print(f"📊 Found {len(tv_nfo_files)} TV NFO files") + + # Check each NFO file for missing dateadded + for i, nfo_file in enumerate(tv_nfo_files): + if i % 1000 == 0 and i > 0: + elapsed = time.time() - scan_start_time + print(f"📊 Progress: {i}/{len(tv_nfo_files)} TV NFO files checked in {elapsed:.1f}s, {len(missing_nfo_files)} missing found") + + # Timeout check + if time.time() - scan_start_time > 180: # 3 minutes + print(f"⏰ TV NFO scan timeout after 3 minutes - checked {i}/{len(tv_nfo_files)} files") + break + + try: + # Use grep to quickly check if dateadded exists + grep_result = subprocess.run( + ['grep', '-l', '', nfo_file], + capture_output=True, text=True, timeout=5 + ) + + # If grep returns non-zero, dateadded is missing + if grep_result.returncode != 0: + missing_nfo_files.append(nfo_file) + + # Log specific files we're looking for + if 'Hudson' in nfo_file and 'S08E05' in nfo_file: + print(f"🔍 FOUND MISSING: Hudson & Rex S08E05 at {nfo_file}") + if 'Star Trek' in nfo_file and 'S03E07' in nfo_file: + print(f"🔍 FOUND MISSING: Star Trek SNW S03E07 at {nfo_file}") + + except subprocess.TimeoutExpired: + print(f"⏰ Timeout checking {nfo_file}") + continue + except Exception as e: + print(f"⚠️ Error checking {nfo_file}: {e}") + continue + + else: + print(f"❌ Error finding TV NFO files: {find_result.stderr}") + + except subprocess.TimeoutExpired: + print("⏰ Find command timeout for TV files") + except Exception as e: + print(f"❌ Error scanning TV directory: {e}") + else: + print(f"❌ TV path does not exist: {tv_path}") + + print(f"📊 Found {len(missing_nfo_files)} NFO files missing dateadded elements") + + # Convert file paths to episode/movie information + for nfo_file in missing_nfo_files: + try: + # Parse episode info from file path + # Example: /media/TV/tv/Series Name/Season 08/Series-S08E05-Episode.nfo + path_parts = nfo_file.split('/') + + # Look for season/episode pattern + season_match = re.search(r'Season\s+(\d+)', nfo_file, re.IGNORECASE) + episode_match = re.search(r'S(\d+)E(\d+)', os.path.basename(nfo_file), re.IGNORECASE) + + if season_match and episode_match: + season = int(episode_match.group(1)) + episode = int(episode_match.group(2)) + + # Extract series name from path + series_path = os.path.dirname(os.path.dirname(nfo_file)) + series_name = os.path.basename(series_path) missing_items["episodes"].append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], + "imdb_id": "unknown", # We'll lookup later if needed + "season": season, + "episode": episode, "series_name": series_name, - "series_path": episode["series_path"], - "dateadded": episode["dateadded"], - "nfo_path": nfo_path, - "db_source": db_source, - "reason": "NFO missing dateadded element" + "series_path": series_path, + "dateadded": None, + "nfo_path": nfo_file, + "reason": "NFO missing dateadded element (filesystem scan)" }) - except ET.ParseError as e: - print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") - missing_items["episodes"].append({ - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "series_name": series_name, - "series_path": episode["series_path"], - "dateadded": episode["dateadded"], - "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" - }) - - # Scan movies - print("🎬 Scanning movies for missing dateadded elements") - movie_query = """ - SELECT DISTINCT imdb_id, path, dateadded, has_video_file - FROM movies - WHERE has_video_file = TRUE - ORDER BY path - """ - - with db.get_connection() as conn: - cursor = conn.cursor() - cursor.execute(movie_query) - movies = [dict(row) for row in cursor.fetchall()] - - print(f"📊 Found {len(movies)} movies in database") - - processed_count = 0 - for movie in movies: - processed_count += 1 - - # Progress logging every 500 items - if processed_count % 500 == 0: - elapsed = time.time() - scan_start_time - print(f"📊 Progress: {processed_count}/{len(movies)} movies processed in {elapsed:.1f}s, {len(missing_items['movies'])} missing found so far") - - # Time-based exit (max 5 minutes total) - if time.time() - scan_start_time > 300: - print(f"⏰ Total scan timeout after 5 minutes - processed {processed_count}/{len(movies)} movies") - break - - # Early exit if we find too many missing items (performance safety) - if len(missing_items['movies']) > 500: - print(f"⚠️ Found {len(missing_items['movies'])} missing movies - stopping scan to prevent performance issues") - break - # Build NFO path using movie path - movie_title = os.path.basename(movie["path"]) - - # Try multiple NFO file naming patterns that might exist - nfo_patterns = [ - f"{movie_title}.nfo", - "movie.nfo", - f"{movie_title}*.nfo" - ] - - nfo_path = None - # Find the actual NFO file that exists - if os.path.exists(movie["path"]): - for pattern in nfo_patterns: - if '*' in pattern: - # Use glob for wildcard patterns - import glob - matches = glob.glob(os.path.join(movie["path"], pattern)) - if matches: - nfo_path = matches[0] # Use first match - break - else: - # Direct file check - test_path = os.path.join(movie["path"], pattern) - if os.path.exists(test_path): - nfo_path = test_path - break - - # Skip if no NFO file found - if not nfo_path: + + except Exception as e: + print(f"⚠️ Error parsing NFO file path {nfo_file}: {e}") continue - - # Check if NFO file has dateadded element + + # Scan movies using filesystem grep approach + print("🎬 Scanning movie NFO files for missing dateadded elements") + + # Find all NFO files in Movie directory + if os.path.exists(movie_path): + print(f"🔍 Finding all Movie NFO files in {movie_path}...") try: - tree = ET.parse(nfo_path) - root = tree.getroot() - dateadded_elem = root.find("dateadded") + # Use find to get all NFO files + find_result = subprocess.run( + ['find', movie_path, '-name', '*.nfo', '-type', 'f'], + capture_output=True, text=True, timeout=60 + ) - # NFO file is missing dateadded element - if dateadded_elem is None or not dateadded_elem.text or not dateadded_elem.text.strip(): - missing_items["movies"].append({ - "imdb_id": movie["imdb_id"], - "title": movie_title, - "path": movie["path"], - "dateadded": movie["dateadded"], - "nfo_path": nfo_path, - "reason": "NFO missing dateadded element" - }) - except ET.ParseError as e: - print(f"⚠️ Could not parse NFO file {nfo_path}: {e}") - missing_items["movies"].append({ - "imdb_id": movie["imdb_id"], - "title": movie_title, - "path": movie["path"], - "dateadded": movie["dateadded"], - "nfo_path": nfo_path, - "error": f"Parse error: {str(e)}" - }) + if find_result.returncode == 0: + movie_nfo_files = find_result.stdout.strip().split('\n') + movie_nfo_files = [f for f in movie_nfo_files if f] # Remove empty strings + print(f"📊 Found {len(movie_nfo_files)} Movie NFO files") + + # Check each NFO file for missing dateadded + for i, nfo_file in enumerate(movie_nfo_files): + if i % 500 == 0 and i > 0: + elapsed = time.time() - scan_start_time + print(f"📊 Progress: {i}/{len(movie_nfo_files)} Movie NFO files checked in {elapsed:.1f}s, {len(missing_items['movies'])} missing found") + + # Timeout check + if time.time() - scan_start_time > 300: # 5 minutes total + print(f"⏰ Movie NFO scan timeout after 5 minutes - checked {i}/{len(movie_nfo_files)} files") + break + + try: + # Use grep to quickly check if dateadded exists + grep_result = subprocess.run( + ['grep', '-l', '', nfo_file], + capture_output=True, text=True, timeout=5 + ) + + # If grep returns non-zero, dateadded is missing + if grep_result.returncode != 0: + # Extract movie info from file path + movie_dir = os.path.dirname(nfo_file) + movie_title = os.path.basename(movie_dir) + + missing_items["movies"].append({ + "imdb_id": "unknown", # We'll lookup later if needed + "title": movie_title, + "path": movie_dir, + "dateadded": None, + "nfo_path": nfo_file, + "reason": "NFO missing dateadded element (filesystem scan)" + }) + + except subprocess.TimeoutExpired: + print(f"⏰ Timeout checking {nfo_file}") + continue + except Exception as e: + print(f"⚠️ Error checking {nfo_file}: {e}") + continue + + else: + print(f"❌ Error finding Movie NFO files: {find_result.stderr}") + + except subprocess.TimeoutExpired: + print("⏰ Find command timeout for Movie files") + except Exception as e: + print(f"❌ Error scanning Movie directory: {e}") + else: + print(f"❌ Movie path does not exist: {movie_path}") total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") From d933ac758526ba511e48a3164fa92f67d857e152 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 13:42:50 -0400 Subject: [PATCH 52/56] update: update the nfo scan --- api/routes.py | 10 ++++++++++ api/web_routes.py | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/api/routes.py b/api/routes.py index 186155a..babcca4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2299,6 +2299,10 @@ def register_routes(app, dependencies: dict): "movies": [] } + # Track total files processed for statistics + total_tv_files_checked = 0 + total_movie_files_checked = 0 + try: # Use filesystem-first approach: find all NFO files missing dateadded print("📺 Scanning NFO files directly for missing dateadded elements") @@ -2334,6 +2338,8 @@ def register_routes(app, dependencies: dict): # Check each NFO file for missing dateadded for i, nfo_file in enumerate(tv_nfo_files): + total_tv_files_checked = i + 1 # Track progress + if i % 1000 == 0 and i > 0: elapsed = time.time() - scan_start_time print(f"📊 Progress: {i}/{len(tv_nfo_files)} TV NFO files checked in {elapsed:.1f}s, {len(missing_nfo_files)} missing found") @@ -2433,6 +2439,8 @@ def register_routes(app, dependencies: dict): # Check each NFO file for missing dateadded for i, nfo_file in enumerate(movie_nfo_files): + total_movie_files_checked = i + 1 # Track progress + if i % 500 == 0 and i > 0: elapsed = time.time() - scan_start_time print(f"📊 Progress: {i}/{len(movie_nfo_files)} Movie NFO files checked in {elapsed:.1f}s, {len(missing_items['movies'])} missing found") @@ -2489,6 +2497,8 @@ def register_routes(app, dependencies: dict): "total_missing": total_missing, "episodes_missing": len(missing_items["episodes"]), "movies_missing": len(missing_items["movies"]), + "total_tv_files_checked": total_tv_files_checked, + "total_movie_files_checked": total_movie_files_checked, "missing_items": missing_items } diff --git a/api/web_routes.py b/api/web_routes.py index e18cae8..3fc9c65 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1594,16 +1594,17 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): return { "success": True, - "total_episodes_checked": len(episodes_missing), # Close enough for UI - "total_movies_checked": len(movies_missing), - "total_items_checked": result['total_missing'], + "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'], "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'] + "movies_missing": result['movies_missing'], + "core_response_keys": list(result.keys()) } } else: From 9fbb9e37ab985570f6b716223c1ba0364adb6e02 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:15:29 -0400 Subject: [PATCH 53/56] web: missing nfo serach updates --- api/routes.py | 283 ++++++++++++++++++++++++++++++++++++++++++++-- api/web_routes.py | 58 +++++++++- 2 files changed, 331 insertions(+), 10 deletions(-) diff --git a/api/routes.py b/api/routes.py index babcca4..e5c25c9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2385,11 +2385,67 @@ def register_routes(app, dependencies: dict): print(f"📊 Found {len(missing_nfo_files)} NFO files missing dateadded elements") - # Convert file paths to episode/movie information + # Convert file paths to episode/movie information with direct NFO parsing + def extract_imdb_from_nfo_content(nfo_file, is_episode=True): + """Extract IMDb ID directly from NFO file content - we already know the file exists""" + imdb_id = "unknown" + + try: + # Parse the NFO file we already know exists (since grep found it missing dateadded) + tree = ET.parse(nfo_file) + root = tree.getroot() + + # Method 1: Check tag + imdb_elem = root.find("imdb") + 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)}") + return imdb_text + + # Method 2: Check tags + for uniqueid in root.findall("uniqueid"): + 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)}") + return imdb_text + + # Method 3: Check for IMDb pattern anywhere in NFO content + nfo_content = ET.tostring(root, encoding='unicode') + 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)}") + return imdb_text + + # Method 4: Fallback - check folder structure for episodes + if is_episode: + series_folder = os.path.dirname(os.path.dirname(nfo_file)) + 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}") + return folder_match.group(1) + else: + # For movies, check movie folder + movie_folder = os.path.dirname(nfo_file) + 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}") + return folder_match.group(1) + + except Exception as e: + print(f"⚠️ Error parsing NFO for IMDb in {nfo_file}: {e}") + + print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}") + return "unknown" + for nfo_file in missing_nfo_files: try: # Parse episode info from file path - # Example: /media/TV/tv/Series Name/Season 08/Series-S08E05-Episode.nfo + # Example: /media/TV/tv/Hudson & Rex (2019) [imdb-tt9111220]/Season 08/Hudson & Rex (2019)-S08E05-Episode.nfo path_parts = nfo_file.split('/') # Look for season/episode pattern @@ -2400,15 +2456,21 @@ def register_routes(app, dependencies: dict): season = int(episode_match.group(1)) episode = int(episode_match.group(2)) - # Extract series name from path + # Extract series name and path series_path = os.path.dirname(os.path.dirname(nfo_file)) series_name = os.path.basename(series_path) + # Extract IMDb ID from NFO content + imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=True) + + # Clean series name (remove year and imdb parts) + clean_series_name = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', series_name).strip() + missing_items["episodes"].append({ - "imdb_id": "unknown", # We'll lookup later if needed + "imdb_id": imdb_id, "season": season, "episode": episode, - "series_name": series_name, + "series_name": clean_series_name, "series_path": series_path, "dateadded": None, "nfo_path": nfo_file, @@ -2460,12 +2522,19 @@ def register_routes(app, dependencies: dict): # If grep returns non-zero, dateadded is missing if grep_result.returncode != 0: # Extract movie info from file path + # Example: /media/Movies/movies/Knives Out (2019) [imdb-tt8946378]/movie.nfo movie_dir = os.path.dirname(nfo_file) - movie_title = os.path.basename(movie_dir) + 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) + + # Clean movie title (remove year and imdb parts) + clean_movie_title = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', movie_folder_name).strip() missing_items["movies"].append({ - "imdb_id": "unknown", # We'll lookup later if needed - "title": movie_title, + "imdb_id": imdb_id, + "title": clean_movie_title, "path": movie_dir, "dateadded": None, "nfo_path": nfo_file, @@ -2489,6 +2558,55 @@ def register_routes(app, dependencies: dict): else: print(f"❌ Movie path does not exist: {movie_path}") + # Database lookup to get actual dateadded values for missing items + print("🔍 Looking up dateadded values from database for missing items...") + + # Enhance episodes with database data + for episode in missing_items["episodes"]: + if episode["imdb_id"] != "unknown": + try: + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT dateadded, source, added_by + FROM episodes + WHERE imdb_id = %s AND season = %s AND episode = %s + LIMIT 1 + """, (episode["imdb_id"], episode["season"], episode["episode"])) + + result = cursor.fetchone() + if result: + episode["dateadded"] = result["dateadded"] + episode["source"] = result.get("source", "database") + episode["added_by"] = result.get("added_by", "unknown") + else: + print(f"⚠️ No database entry for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") + except Exception as e: + print(f"⚠️ Database lookup failed for episode {episode['imdb_id']}: {e}") + + # Enhance movies with database data + for movie in missing_items["movies"]: + if movie["imdb_id"] != "unknown": + try: + with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT dateadded, source, added_by + FROM movies + WHERE imdb_id = %s + LIMIT 1 + """, (movie["imdb_id"],)) + + result = cursor.fetchone() + if result: + movie["dateadded"] = result["dateadded"] + movie["source"] = result.get("source", "database") + movie["added_by"] = result.get("added_by", "unknown") + else: + print(f"⚠️ No database entry for movie {movie['imdb_id']}") + except Exception as e: + print(f"⚠️ Database lookup failed for movie {movie['imdb_id']}: {e}") + total_missing = len(missing_items["episodes"]) + len(missing_items["movies"]) print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements") @@ -2510,6 +2628,155 @@ def register_routes(app, dependencies: dict): async def _nfo_repair_scan(): return await nfo_repair_scan() + async def nfo_repair_fix(): + """Fix missing dateadded elements in NFO files using database values""" + import os + import xml.etree.ElementTree as ET + from datetime import datetime + + config = dependencies.get("config") + db = dependencies.get("db") + nfo_manager = dependencies.get("nfo_manager") + + if not config or not db: + raise HTTPException(status_code=500, detail=f"Dependencies not available") + + print("🔧 Starting NFO repair fix process from core container") + + # First, run the scan to identify missing items + scan_result = await nfo_repair_scan() + if scan_result["status"] != "success": + return {"status": "error", "message": "Scan failed"} + + missing_items = scan_result["missing_items"] + total_episodes = len(missing_items["episodes"]) + total_movies = len(missing_items["movies"]) + + print(f"🔍 Found {total_episodes} episodes and {total_movies} movies to fix") + + fixed_count = 0 + failed_count = 0 + results = [] + + # Fix episodes + for episode in missing_items["episodes"]: + if episode.get("dateadded") and episode.get("imdb_id") != "unknown": + try: + nfo_path = episode["nfo_path"] + dateadded_value = episode["dateadded"] + + # Format dateadded for NFO file + if isinstance(dateadded_value, str): + # Parse string datetime + dt = datetime.fromisoformat(dateadded_value.replace('Z', '+00:00')) + else: + dt = dateadded_value + + # Format as NFO expects: 2024-10-15 12:34:56 + nfo_dateadded = dt.strftime('%Y-%m-%d %H:%M:%S') + + # Read and modify NFO file + if os.path.exists(nfo_path): + tree = ET.parse(nfo_path) + root = tree.getroot() + + # Remove existing dateadded element if present + existing = root.find("dateadded") + if existing is not None: + root.remove(existing) + + # Add new dateadded element + dateadded_elem = ET.SubElement(root, "dateadded") + dateadded_elem.text = nfo_dateadded + + # Write back to file + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) + + fixed_count += 1 + results.append({ + "type": "episode", + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "nfo_path": nfo_path, + "dateadded": nfo_dateadded, + "status": "fixed" + }) + + print(f"✅ Fixed episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") + else: + failed_count += 1 + print(f"❌ NFO file not found: {nfo_path}") + + except Exception as e: + failed_count += 1 + print(f"❌ Failed to fix episode {episode.get('imdb_id', 'unknown')}: {e}") + + # Fix movies + for movie in missing_items["movies"]: + if movie.get("dateadded") and movie.get("imdb_id") != "unknown": + try: + nfo_path = movie["nfo_path"] + dateadded_value = movie["dateadded"] + + # Format dateadded for NFO file + if isinstance(dateadded_value, str): + dt = datetime.fromisoformat(dateadded_value.replace('Z', '+00:00')) + else: + dt = dateadded_value + + nfo_dateadded = dt.strftime('%Y-%m-%d %H:%M:%S') + + # Read and modify NFO file + if os.path.exists(nfo_path): + tree = ET.parse(nfo_path) + root = tree.getroot() + + # Remove existing dateadded element if present + existing = root.find("dateadded") + if existing is not None: + root.remove(existing) + + # Add new dateadded element + dateadded_elem = ET.SubElement(root, "dateadded") + dateadded_elem.text = nfo_dateadded + + # Write back to file + tree.write(nfo_path, encoding='utf-8', xml_declaration=True) + + fixed_count += 1 + results.append({ + "type": "movie", + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "nfo_path": nfo_path, + "dateadded": nfo_dateadded, + "status": "fixed" + }) + + print(f"✅ Fixed movie {movie['imdb_id']} ({movie['title']})") + else: + failed_count += 1 + print(f"❌ NFO file not found: {nfo_path}") + + except Exception as e: + failed_count += 1 + print(f"❌ Failed to fix movie {movie.get('imdb_id', 'unknown')}: {e}") + + print(f"✅ NFO repair fix complete: {fixed_count} fixed, {failed_count} failed") + + return { + "status": "success", + "total_processed": total_episodes + total_movies, + "fixed_count": fixed_count, + "failed_count": failed_count, + "results": results[:20] # Show first 20 for UI + } + + @app.post("/admin/nfo-repair-fix") + async def _nfo_repair_fix(): + return await nfo_repair_fix() + # --------------------------- # Core API - No Web Interface # --------------------------- diff --git a/api/web_routes.py b/api/web_routes.py index 3fc9c65..fc0a233 100644 --- a/api/web_routes.py +++ b/api/web_routes.py @@ -1627,6 +1627,55 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict): } +async def fix_nfo_missing_dateadded(dependencies: dict): + """Fix NFO files missing dateadded elements via core container""" + config = dependencies["config"] + + try: + print("🔧 Calling core container for NFO repair fix...") + + # Call the core container's NFO repair fix endpoint + import aiohttp + import asyncio + + core_url = f"http://nfoguard:{config.core_api_port}/admin/nfo-repair-fix" + print(f"🔍 DEBUG: Calling core container at: {core_url}") + + timeout = aiohttp.ClientTimeout(total=600.0) # 10 minute timeout for fix operation + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(core_url) as response: + if response.status == 200: + result = await response.json() + print(f"✅ Core container fix complete: {result.get('fixed_count', 0)} fixed, {result.get('failed_count', 0)} failed") + + return { + "success": True, + "fixed_count": result.get("fixed_count", 0), + "failed_count": result.get("failed_count", 0), + "total_processed": result.get("total_processed", 0), + "results": result.get("results", []), + "message": f"Successfully fixed {result.get('fixed_count', 0)} NFO files" + } + else: + error_text = await response.text() + print(f"❌ Core container fix failed: {response.status} - {error_text}") + return { + "success": False, + "error": f"Core container fix failed: {response.status}", + "message": f"Failed to fix NFO files: {error_text}" + } + + except Exception as e: + print(f"❌ Error calling core container for NFO repair fix: {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": f"Core container communication failed: {str(e)}", + "message": f"Failed to fix NFO files: {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"] @@ -1747,8 +1796,13 @@ def register_database_admin_routes(app, 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""" - return await bulk_update_nfo_files(dependencies, imdb_ids, fix_all) + """Bulk update NFO files with missing dateadded via core container""" + # For now, we only support fix_all mode using the new core container approach + if fix_all or not imdb_ids: + return await fix_nfo_missing_dateadded(dependencies) + else: + # Legacy mode for specific IMDb IDs - fallback to old method + return await bulk_update_nfo_files(dependencies, imdb_ids, fix_all) @app.get("/api/admin/database/quick-queries") async def api_database_quick_queries(): From c6b6b0ef64b1b23568ad83a8fa991c574d6d31a3 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:23:35 -0400 Subject: [PATCH 54/56] web: table update and nfo scan update --- api/routes.py | 61 ++++++++++++-------- nfoguard-web/static/css/styles.css | 91 ++++++++++++++++++++++++++++++ nfoguard-web/static/index.html | 4 +- nfoguard-web/static/js/app.js | 54 ++++++++++++------ 4 files changed, 168 insertions(+), 42 deletions(-) diff --git a/api/routes.py b/api/routes.py index e5c25c9..c5435c7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2391,7 +2391,7 @@ def register_routes(app, dependencies: dict): imdb_id = "unknown" try: - # Parse the NFO file we already know exists (since grep found it missing dateadded) + # Try to parse the NFO file we already know exists (since grep found it missing dateadded) tree = ET.parse(nfo_file) root = tree.getroot() @@ -2418,26 +2418,43 @@ def register_routes(app, dependencies: dict): imdb_text = content_match.group(1) print(f"✅ Found IMDb {imdb_text} in NFO content: {os.path.basename(nfo_file)}") return imdb_text - - # Method 4: Fallback - check folder structure for episodes - if is_episode: - series_folder = os.path.dirname(os.path.dirname(nfo_file)) - 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}") - return folder_match.group(1) - else: - # For movies, check movie folder - movie_folder = os.path.dirname(nfo_file) - 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}") - return folder_match.group(1) + 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}") + try: + # Fallback: Read as text and search for IMDb patterns + with open(nfo_file, 'r', encoding='utf-8', errors='ignore') as f: + nfo_text = f.read() + + # Search for IMDb patterns in raw text + 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)}") + return imdb_text + except Exception as text_error: + print(f"⚠️ Error reading NFO as text {nfo_file}: {text_error}") + except Exception as e: - print(f"⚠️ Error parsing NFO for IMDb in {nfo_file}: {e}") + print(f"⚠️ Unexpected error parsing NFO {nfo_file}: {e}") + + # Method 4: Fallback - check folder structure + if is_episode: + series_folder = os.path.dirname(os.path.dirname(nfo_file)) + 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}") + return folder_match.group(1) + else: + # For movies, check movie folder + movie_folder = os.path.dirname(nfo_file) + 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}") + return folder_match.group(1) print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}") return "unknown" @@ -2568,7 +2585,7 @@ def register_routes(app, dependencies: dict): with db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - SELECT dateadded, source, added_by + SELECT dateadded, source FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s LIMIT 1 @@ -2578,7 +2595,6 @@ def register_routes(app, dependencies: dict): if result: episode["dateadded"] = result["dateadded"] episode["source"] = result.get("source", "database") - episode["added_by"] = result.get("added_by", "unknown") else: print(f"⚠️ No database entry for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") except Exception as e: @@ -2591,7 +2607,7 @@ def register_routes(app, dependencies: dict): with db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - SELECT dateadded, source, added_by + SELECT dateadded, source FROM movies WHERE imdb_id = %s LIMIT 1 @@ -2601,7 +2617,6 @@ def register_routes(app, dependencies: dict): if result: movie["dateadded"] = result["dateadded"] movie["source"] = result.get("source", "database") - movie["added_by"] = result.get("added_by", "unknown") else: print(f"⚠️ No database entry for movie {movie['imdb_id']}") except Exception as e: diff --git a/nfoguard-web/static/css/styles.css b/nfoguard-web/static/css/styles.css index 0c06736..42e54f1 100644 --- a/nfoguard-web/static/css/styles.css +++ b/nfoguard-web/static/css/styles.css @@ -958,6 +958,97 @@ body { border-radius: 0.375rem; } +/* NFO Repair Table Styling */ +.nfo-repair-table-container { + background: white; + border-radius: 0.5rem; + box-shadow: var(--shadow); + overflow-x: auto; + overflow-y: auto; + max-height: 600px; + margin-bottom: 1rem; + border: 1px solid var(--border-color); +} + +.nfo-repair-table { + width: 100%; + min-width: 800px; + border-collapse: collapse; + font-size: 0.875rem; + background: white; +} + +.nfo-repair-table th { + background-color: var(--dark-color); + color: white; + padding: 0.75rem 0.5rem; + text-align: left; + font-weight: 600; + position: sticky; + top: 0; + z-index: 10; +} + +.nfo-repair-table td { + padding: 0.75rem 0.5rem; + border-bottom: 1px solid var(--border-color); + vertical-align: top; +} + +.nfo-repair-table tr:hover { + background-color: #f8f9fa; +} + +.media-type-badge { + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.media-type-badge.episode { + background-color: #e3f2fd; + color: #1976d2; +} + +.media-type-badge.movie { + background-color: #f3e5f5; + color: #7b1fa2; +} + +.status-ready { + color: #2e7d32; + font-weight: 600; + font-size: 0.75rem; +} + +.status-missing { + color: #d32f2f; + font-weight: 600; + font-size: 0.75rem; +} + +.text-center { + text-align: center; +} + +.text-muted { + color: #6c757d; +} + +.nfo-fix-actions { + margin-top: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 0.5rem; + text-align: center; +} + +.nfo-fix-actions .btn { + margin-bottom: 0.5rem; +} + .admin-tools { display: flex; flex-direction: column; diff --git a/nfoguard-web/static/index.html b/nfoguard-web/static/index.html index d4860b9..fbbbcd6 100644 --- a/nfoguard-web/static/index.html +++ b/nfoguard-web/static/index.html @@ -4,7 +4,7 @@ NFOGuard - Database Management - + @@ -533,6 +533,6 @@
- + \ No newline at end of file diff --git a/nfoguard-web/static/js/app.js b/nfoguard-web/static/js/app.js index 1eb8e5a..7daf249 100644 --- a/nfoguard-web/static/js/app.js +++ b/nfoguard-web/static/js/app.js @@ -1847,17 +1847,18 @@ async function checkMissingNFODates() { html += `
Items with missing dateadded (showing first 50):
-
-
SeriesTypeIMDb ID Season Episode Database Date
${ep.imdb_id}${ep.season}${ep.episode}${new Date(ep.dateadded).toLocaleString()}${ep.source}${ep.nfo_path}${item.media_type || 'episode'}${item.imdb_id}${item.season || 'N/A'}${item.episode || 'N/A'}${displayDate}${item.source || 'unknown'}${item.nfo_path}
+
+
- - - - - - - + + + + + + + + @@ -1865,20 +1866,39 @@ async function checkMissingNFODates() { (response.items || response.episodes || []).forEach(item => { const displayDate = item.dateadded ? new Date(item.dateadded).toLocaleString() : 'None'; + const seriesName = item.series_name || item.title || 'Unknown'; + const nfoFileName = item.nfo_path ? item.nfo_path.split('/').pop() : 'Unknown'; + html += ` - - - - - - - + + + + + + + + `; }); - html += '
TypeIMDb IDSeasonEpisodeDatabase DateSourceNFO PathTypeIMDb IDSeasonEpisodeDatabase DateSourceSeries/MovieActions
${item.media_type || 'episode'}${item.imdb_id}${item.season || 'N/A'}${item.episode || 'N/A'}${displayDate}${item.source || 'unknown'}${item.nfo_path}${item.media_type || 'episode'}${item.imdb_id}${item.season || 'N/A'}${item.episode || 'N/A'}${displayDate}${item.source || 'unknown'}${seriesName}
${nfoFileName}
+ ${item.dateadded ? + '✅ Ready to fix' : + '❌ No DB date' + } +
'; + html += ` + + + +
+ +

This will automatically add dateadded elements to NFO files using database values.

+
+ `; } else { html += '
✅ All NFO files have dateadded elements!
'; } From d1480e1b96fd6064795354ad9541664bd1208131 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:30:11 -0400 Subject: [PATCH 55/56] improvment: nfo verifcations --- api/routes.py | 190 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 165 insertions(+), 25 deletions(-) diff --git a/api/routes.py b/api/routes.py index c5435c7..ac43d65 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2579,6 +2579,9 @@ def register_routes(app, dependencies: dict): print("🔍 Looking up dateadded values from database for missing items...") # Enhance episodes with database data + episodes_with_dates = 0 + episodes_missing_db = 0 + for episode in missing_items["episodes"]: if episode["imdb_id"] != "unknown": try: @@ -2595,12 +2598,47 @@ def register_routes(app, dependencies: dict): if result: episode["dateadded"] = result["dateadded"] episode["source"] = result.get("source", "database") + episodes_with_dates += 1 + print(f"✅ Found database date for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}: {result['dateadded']}") else: - print(f"⚠️ No database entry for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") + episodes_missing_db += 1 + # Try to find other episodes from same series to estimate date + cursor.execute(""" + SELECT dateadded, season, episode + FROM episodes + WHERE imdb_id = %s AND dateadded IS NOT NULL + ORDER BY season DESC, episode DESC + LIMIT 3 + """, (episode["imdb_id"],)) + + similar_episodes = cursor.fetchall() + if similar_episodes: + latest_date = similar_episodes[0]["dateadded"] + episode["dateadded"] = latest_date + episode["source"] = f"estimated_from_series_latest" + print(f"🔮 Estimated date for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d} from latest episode: {latest_date}") + else: + # Use NFO file modification time as fallback + try: + import os + from datetime import datetime + if os.path.exists(episode["nfo_path"]): + file_mtime = os.path.getmtime(episode["nfo_path"]) + file_date = datetime.fromtimestamp(file_mtime) + episode["dateadded"] = file_date + episode["source"] = "nfo_file_mtime" + print(f"📅 Using NFO file date for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}: {file_date}") + else: + print(f"❌ No fallback date available for {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") + except Exception as fallback_error: + print(f"⚠️ Fallback date lookup failed for {episode['imdb_id']}: {fallback_error}") except Exception as e: print(f"⚠️ Database lookup failed for episode {episode['imdb_id']}: {e}") # Enhance movies with database data + movies_with_dates = 0 + movies_missing_db = 0 + for movie in missing_items["movies"]: if movie["imdb_id"] != "unknown": try: @@ -2617,11 +2655,34 @@ def register_routes(app, dependencies: dict): if result: movie["dateadded"] = result["dateadded"] movie["source"] = result.get("source", "database") + movies_with_dates += 1 + print(f"✅ Found database date for movie {movie['imdb_id']}: {result['dateadded']}") else: - print(f"⚠️ No database entry for movie {movie['imdb_id']}") + movies_missing_db += 1 + # Use NFO file modification time as fallback for movies + try: + import os + from datetime import datetime + if os.path.exists(movie["nfo_path"]): + file_mtime = os.path.getmtime(movie["nfo_path"]) + file_date = datetime.fromtimestamp(file_mtime) + movie["dateadded"] = file_date + movie["source"] = "nfo_file_mtime" + print(f"📅 Using NFO file date for movie {movie['imdb_id']}: {file_date}") + else: + print(f"❌ No fallback date available for movie {movie['imdb_id']}") + except Exception as fallback_error: + print(f"⚠️ Fallback date lookup failed for movie {movie['imdb_id']}: {fallback_error}") except Exception as e: print(f"⚠️ Database lookup failed for movie {movie['imdb_id']}: {e}") + # Print summary statistics + total_episodes = len(missing_items["episodes"]) + total_movies = len(missing_items["movies"]) + print(f"📊 Database lookup summary:") + 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") @@ -2707,18 +2768,52 @@ def register_routes(app, dependencies: dict): # Write back to file tree.write(nfo_path, encoding='utf-8', xml_declaration=True) - fixed_count += 1 - results.append({ - "type": "episode", - "imdb_id": episode["imdb_id"], - "season": episode["season"], - "episode": episode["episode"], - "nfo_path": nfo_path, - "dateadded": nfo_dateadded, - "status": "fixed" - }) - - print(f"✅ Fixed episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") + # VERIFY the write was successful by re-reading the file + try: + verify_tree = ET.parse(nfo_path) + verify_root = verify_tree.getroot() + verify_dateadded = verify_root.find("dateadded") + + if verify_dateadded is not None and verify_dateadded.text and verify_dateadded.text.strip(): + # Verification successful + fixed_count += 1 + results.append({ + "type": "episode", + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "nfo_path": nfo_path, + "dateadded": nfo_dateadded, + "verified_dateadded": verify_dateadded.text.strip(), + "status": "fixed_and_verified" + }) + print(f"✅ Fixed and verified episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}") + else: + # Verification failed - dateadded missing after write + failed_count += 1 + results.append({ + "type": "episode", + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "nfo_path": nfo_path, + "dateadded": nfo_dateadded, + "status": "write_failed_verification" + }) + print(f"❌ Write verification FAILED for episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d} - dateadded not found after write") + except Exception as verify_error: + # Verification failed due to parse error + failed_count += 1 + results.append({ + "type": "episode", + "imdb_id": episode["imdb_id"], + "season": episode["season"], + "episode": episode["episode"], + "nfo_path": nfo_path, + "status": "verification_parse_error", + "error": str(verify_error) + }) + print(f"❌ Verification parse error for episode {episode['imdb_id']} S{episode['season']:02d}E{episode['episode']:02d}: {verify_error}") else: failed_count += 1 print(f"❌ NFO file not found: {nfo_path}") @@ -2759,17 +2854,49 @@ def register_routes(app, dependencies: dict): # Write back to file tree.write(nfo_path, encoding='utf-8', xml_declaration=True) - fixed_count += 1 - results.append({ - "type": "movie", - "imdb_id": movie["imdb_id"], - "title": movie["title"], - "nfo_path": nfo_path, - "dateadded": nfo_dateadded, - "status": "fixed" - }) - - print(f"✅ Fixed movie {movie['imdb_id']} ({movie['title']})") + # VERIFY the write was successful by re-reading the file + try: + verify_tree = ET.parse(nfo_path) + verify_root = verify_tree.getroot() + verify_dateadded = verify_root.find("dateadded") + + if verify_dateadded is not None and verify_dateadded.text and verify_dateadded.text.strip(): + # Verification successful + fixed_count += 1 + results.append({ + "type": "movie", + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "nfo_path": nfo_path, + "dateadded": nfo_dateadded, + "verified_dateadded": verify_dateadded.text.strip(), + "status": "fixed_and_verified" + }) + print(f"✅ Fixed and verified movie {movie['imdb_id']} ({movie['title']})") + else: + # Verification failed - dateadded missing after write + failed_count += 1 + results.append({ + "type": "movie", + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "nfo_path": nfo_path, + "dateadded": nfo_dateadded, + "status": "write_failed_verification" + }) + print(f"❌ Write verification FAILED for movie {movie['imdb_id']} ({movie['title']}) - dateadded not found after write") + except Exception as verify_error: + # Verification failed due to parse error + failed_count += 1 + results.append({ + "type": "movie", + "imdb_id": movie["imdb_id"], + "title": movie["title"], + "nfo_path": nfo_path, + "status": "verification_parse_error", + "error": str(verify_error) + }) + print(f"❌ Verification parse error for movie {movie['imdb_id']} ({movie['title']}): {verify_error}") else: failed_count += 1 print(f"❌ NFO file not found: {nfo_path}") @@ -2780,11 +2907,24 @@ def register_routes(app, dependencies: dict): print(f"✅ NFO repair fix complete: {fixed_count} fixed, {failed_count} failed") + # Final summary with detailed statistics + verification_summary = { + "fixed_and_verified": sum(1 for r in results if r.get("status") == "fixed_and_verified"), + "write_failed_verification": sum(1 for r in results if r.get("status") == "write_failed_verification"), + "verification_parse_error": sum(1 for r in results if r.get("status") == "verification_parse_error") + } + + print(f"📊 Final summary: {fixed_count} fixed, {failed_count} failed") + print(f" ✅ Verified successful: {verification_summary['fixed_and_verified']}") + print(f" ❌ Write verification failed: {verification_summary['write_failed_verification']}") + print(f" ⚠️ Verification parse errors: {verification_summary['verification_parse_error']}") + return { "status": "success", "total_processed": total_episodes + total_movies, "fixed_count": fixed_count, "failed_count": failed_count, + "verification_summary": verification_summary, "results": results[:20] # Show first 20 for UI } From d6488a99bd7813501832bb1fd093e44ab7a7b311 Mon Sep 17 00:00:00 2001 From: SBCrumb Date: Sun, 26 Oct 2025 15:42:05 -0400 Subject: [PATCH 56/56] nfo: improvments to processing --- api/routes.py | 91 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/api/routes.py b/api/routes.py index ac43d65..e9e6209 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2317,9 +2317,9 @@ def register_routes(app, dependencies: dict): import subprocess import re - # Find all NFO files and check which ones are missing dateadded - # This is much faster than database-driven approach - missing_nfo_files = [] + # Separate tracking for TV and movie missing files + missing_tv_nfo_files = [] + missing_movie_nfo_files = [] # Find all NFO files in TV directory if os.path.exists(tv_path): @@ -2342,7 +2342,7 @@ def register_routes(app, dependencies: dict): if i % 1000 == 0 and i > 0: elapsed = time.time() - scan_start_time - print(f"📊 Progress: {i}/{len(tv_nfo_files)} TV NFO files checked in {elapsed:.1f}s, {len(missing_nfo_files)} missing found") + print(f"📊 Progress: {i}/{len(tv_nfo_files)} TV NFO files checked in {elapsed:.1f}s, {len(missing_tv_nfo_files)} missing found") # Timeout check if time.time() - scan_start_time > 180: # 3 minutes @@ -2358,7 +2358,7 @@ def register_routes(app, dependencies: dict): # If grep returns non-zero, dateadded is missing if grep_result.returncode != 0: - missing_nfo_files.append(nfo_file) + missing_tv_nfo_files.append(nfo_file) # Log specific files we're looking for if 'Hudson' in nfo_file and 'S08E05' in nfo_file: @@ -2383,9 +2383,9 @@ def register_routes(app, dependencies: dict): else: print(f"❌ TV path does not exist: {tv_path}") - print(f"📊 Found {len(missing_nfo_files)} NFO files missing dateadded elements") + print(f"📊 Found {len(missing_tv_nfo_files)} TV NFO files missing dateadded elements") - # Convert file paths to episode/movie information with direct NFO parsing + # Convert TV file paths to episode information with direct NFO parsing def extract_imdb_from_nfo_content(nfo_file, is_episode=True): """Extract IMDb ID directly from NFO file content - we already know the file exists""" imdb_id = "unknown" @@ -2459,7 +2459,7 @@ def register_routes(app, dependencies: dict): print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}") return "unknown" - for nfo_file in missing_nfo_files: + for nfo_file in missing_tv_nfo_files: try: # Parse episode info from file path # Example: /media/TV/tv/Hudson & Rex (2019) [imdb-tt9111220]/Season 08/Hudson & Rex (2019)-S08E05-Episode.nfo @@ -2522,7 +2522,7 @@ def register_routes(app, dependencies: dict): if i % 500 == 0 and i > 0: elapsed = time.time() - scan_start_time - print(f"📊 Progress: {i}/{len(movie_nfo_files)} Movie NFO files checked in {elapsed:.1f}s, {len(missing_items['movies'])} missing found") + print(f"📊 Progress: {i}/{len(movie_nfo_files)} Movie NFO files checked in {elapsed:.1f}s, {len(missing_movie_nfo_files)} missing found") # Timeout check if time.time() - scan_start_time > 300: # 5 minutes total @@ -2538,25 +2538,7 @@ def register_routes(app, dependencies: dict): # If grep returns non-zero, dateadded is missing if grep_result.returncode != 0: - # Extract movie info from file path - # Example: /media/Movies/movies/Knives Out (2019) [imdb-tt8946378]/movie.nfo - movie_dir = os.path.dirname(nfo_file) - 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) - - # Clean movie title (remove year and imdb parts) - clean_movie_title = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', movie_folder_name).strip() - - missing_items["movies"].append({ - "imdb_id": imdb_id, - "title": clean_movie_title, - "path": movie_dir, - "dateadded": None, - "nfo_path": nfo_file, - "reason": "NFO missing dateadded element (filesystem scan)" - }) + missing_movie_nfo_files.append(nfo_file) except subprocess.TimeoutExpired: print(f"⏰ Timeout checking {nfo_file}") @@ -2575,6 +2557,34 @@ def register_routes(app, dependencies: dict): else: print(f"❌ Movie path does not exist: {movie_path}") + # 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: + try: + # Extract movie info from file path + # Example: /media/Movies/movies/Knives Out (2019) [imdb-tt8946378]/movie.nfo + movie_dir = os.path.dirname(nfo_file) + 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) + + # Clean movie title (remove year and imdb parts) + clean_movie_title = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', movie_folder_name).strip() + + missing_items["movies"].append({ + "imdb_id": imdb_id, + "title": clean_movie_title, + "path": movie_dir, + "dateadded": None, + "nfo_path": nfo_file, + "reason": "NFO missing dateadded element (filesystem scan)" + }) + except Exception as e: + print(f"⚠️ Error parsing movie NFO file path {nfo_file}: {e}") + continue + # Database lookup to get actual dateadded values for missing items print("🔍 Looking up dateadded values from database for missing items...") @@ -2765,6 +2775,19 @@ def register_routes(app, dependencies: dict): dateadded_elem = ET.SubElement(root, "dateadded") dateadded_elem.text = nfo_dateadded + # Add source attribution (similar to what NFOGuard normally adds) + # Check if source element exists, if not create it + source_elem = root.find("source") + if source_elem is None: + source_elem = ET.SubElement(root, "source") + source_elem.text = f"NFOGuard repair - {episode.get('source', 'database')}" + + # Add addedby attribution if not present + addedby_elem = root.find("addedby") + if addedby_elem is None: + addedby_elem = ET.SubElement(root, "addedby") + addedby_elem.text = "NFOGuard" + # Write back to file tree.write(nfo_path, encoding='utf-8', xml_declaration=True) @@ -2851,6 +2874,18 @@ def register_routes(app, dependencies: dict): dateadded_elem = ET.SubElement(root, "dateadded") dateadded_elem.text = nfo_dateadded + # Add source attribution (similar to what NFOGuard normally adds) + source_elem = root.find("source") + if source_elem is None: + source_elem = ET.SubElement(root, "source") + source_elem.text = f"NFOGuard repair - {movie.get('source', 'database')}" + + # Add addedby attribution if not present + addedby_elem = root.find("addedby") + if addedby_elem is None: + addedby_elem = ET.SubElement(root, "addedby") + addedby_elem.text = "NFOGuard" + # Write back to file tree.write(nfo_path, encoding='utf-8', xml_declaration=True)