Implement Melora: *arr media arrival webhook announcer for Matrix
Replace Bellhop scaffolding with full Melora implementation: - FastAPI webhook endpoints for Radarr, Sonarr, and Lidarr - matrix-nio integration with threaded room posting - SQLite for thread root persistence and event deduplication - Message formatting with plain text and HTML for each media type - Shared secret authentication via X-Arr-Webhook-Secret header - Updated dependencies, configuration, and documentation https://claude.ai/code/session_01DuzWyMMXvLMB4VxEwJyV4X
This commit is contained in:
130
app/webhooks.py
Normal file
130
app/webhooks.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, Request, Response
|
||||
|
||||
from app.config import config
|
||||
from app.database import get_thread_root, is_event_posted, record_posted_event
|
||||
from app.formatters import format_lidarr, format_radarr, format_sonarr
|
||||
from app.matrix import post_to_thread
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhook")
|
||||
|
||||
SOURCE_MAP = {
|
||||
"radarr": {"media_type": "movies", "formatter": format_radarr, "id_field": "movie"},
|
||||
"sonarr": {"media_type": "shows", "formatter": format_sonarr, "id_field": "series"},
|
||||
"lidarr": {"media_type": "music", "formatter": format_lidarr, "id_field": "artist"},
|
||||
}
|
||||
|
||||
|
||||
def _validate_secret(header_value: str | None) -> bool:
|
||||
return header_value == config.WEBHOOK_SECRET
|
||||
|
||||
|
||||
def _extract_event_key(source: str, payload: dict) -> str | None:
|
||||
if source == "radarr":
|
||||
movie = payload.get("movie", {})
|
||||
movie_id = movie.get("id")
|
||||
if movie_id is not None:
|
||||
return f"radarr-{movie_id}"
|
||||
elif source == "sonarr":
|
||||
episodes = payload.get("episodes", [])
|
||||
if episodes:
|
||||
ep_id = episodes[0].get("id")
|
||||
if ep_id is not None:
|
||||
return f"sonarr-{ep_id}"
|
||||
elif source == "lidarr":
|
||||
album = payload.get("album", {})
|
||||
album_id = album.get("id")
|
||||
if album_id is not None:
|
||||
return f"lidarr-{album_id}"
|
||||
return None
|
||||
|
||||
|
||||
def _extract_title(source: str, payload: dict) -> str:
|
||||
if source == "radarr":
|
||||
return payload.get("movie", {}).get("title", "Unknown")
|
||||
elif source == "sonarr":
|
||||
return payload.get("series", {}).get("title", "Unknown")
|
||||
elif source == "lidarr":
|
||||
return payload.get("artist", {}).get("name", "Unknown")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
async def _handle_webhook(source: str, payload: dict, secret: str | None) -> Response:
|
||||
if not _validate_secret(secret):
|
||||
return Response(status_code=401, content="Unauthorized")
|
||||
|
||||
event_type = payload.get("eventType", "")
|
||||
if event_type != "Download":
|
||||
log.debug("Ignoring %s event from %s", event_type, source)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
info = SOURCE_MAP[source]
|
||||
media_type = info["media_type"]
|
||||
formatter = info["formatter"]
|
||||
is_upgrade = payload.get("isUpgrade", False)
|
||||
|
||||
event_key = _extract_event_key(source, payload)
|
||||
if event_key:
|
||||
if await is_event_posted(event_key):
|
||||
log.debug("Duplicate event %s, skipping", event_key)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
try:
|
||||
plain, html = formatter(payload)
|
||||
except Exception:
|
||||
log.exception("Failed to format %s payload", source)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
thread_root_id = await get_thread_root(media_type)
|
||||
if not thread_root_id:
|
||||
log.error("No thread root for %s — cannot post", media_type)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
event_id = await post_to_thread(thread_root_id, plain, html)
|
||||
if event_id and event_key:
|
||||
title = _extract_title(source, payload)
|
||||
await record_posted_event(event_key, title, media_type, is_upgrade)
|
||||
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
|
||||
@router.post("/radarr")
|
||||
async def webhook_radarr(
|
||||
request: Request,
|
||||
x_arr_webhook_secret: str | None = Header(None),
|
||||
):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
log.exception("Failed to parse Radarr payload")
|
||||
return Response(status_code=200, content="OK")
|
||||
return await _handle_webhook("radarr", payload, x_arr_webhook_secret)
|
||||
|
||||
|
||||
@router.post("/sonarr")
|
||||
async def webhook_sonarr(
|
||||
request: Request,
|
||||
x_arr_webhook_secret: str | None = Header(None),
|
||||
):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
log.exception("Failed to parse Sonarr payload")
|
||||
return Response(status_code=200, content="OK")
|
||||
return await _handle_webhook("sonarr", payload, x_arr_webhook_secret)
|
||||
|
||||
|
||||
@router.post("/lidarr")
|
||||
async def webhook_lidarr(
|
||||
request: Request,
|
||||
x_arr_webhook_secret: str | None = Header(None),
|
||||
):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
log.exception("Failed to parse Lidarr payload")
|
||||
return Response(status_code=200, content="OK")
|
||||
return await _handle_webhook("lidarr", payload, x_arr_webhook_secret)
|
||||
Reference in New Issue
Block a user