feat: add support for Maintainarr webhook
Local Docker Build (Dev) / build-dev (push) Successful in 4s
Local Docker Build (Dev) / build-dev (push) Successful in 4s
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
+166
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user