dev #62
@@ -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"
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user