feat: add support for Maintainarr webhook
Local Docker Build (Dev) / build-dev (push) Successful in 4s

This commit is contained in:
2025-10-24 16:30:08 -04:00
parent f6c5ccd2b6
commit e054c945ff
4 changed files with 397 additions and 1 deletions
+196
View File
@@ -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
+12
View File
@@ -31,6 +31,18 @@ class RadarrWebhook(BaseModel):
extra = "allow" 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): class HealthResponse(BaseModel):
"""Health check response model""" """Health check response model"""
status: str status: str
+166 -1
View File
@@ -12,7 +12,7 @@ from typing import Optional
# Import models # Import models
from api.models import ( from api.models import (
SonarrWebhook, RadarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest, SonarrWebhook, RadarrWebhook, MaintainarrWebhook, HealthResponse, TVSeasonRequest, TVEpisodeRequest,
MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest MovieUpdateRequest, EpisodeUpdateRequest, BulkUpdateRequest
) )
# Web routes removed - handled by separate web container # 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)} 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: async def health(dependencies: dict) -> HealthResponse:
"""Health check endpoint with Radarr database status""" """Health check endpoint with Radarr database status"""
db = dependencies["db"] db = dependencies["db"]
@@ -1933,6 +2094,10 @@ def register_routes(app, dependencies: dict):
async def _radarr_webhook(request: Request, background_tasks: BackgroundTasks): async def _radarr_webhook(request: Request, background_tasks: BackgroundTasks):
return await radarr_webhook(request, background_tasks, dependencies) 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") @app.get("/health")
async def _health() -> HealthResponse: async def _health() -> HealthResponse:
return await health(dependencies) return await health(dependencies)
+23
View File
@@ -441,6 +441,29 @@ class NFOGuardDatabase:
return deleted_count > 0 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]: def delete_orphaned_movies(self) -> List[Dict]:
""" """
Find and delete movies that don't have corresponding video files on disk Find and delete movies that don't have corresponding video files on disk