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