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:
Claude
2026-02-28 07:38:07 +00:00
parent e6e47fd470
commit aaacd0b9bf
13 changed files with 557 additions and 222 deletions

130
app/webhooks.py Normal file
View 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)