v0.3.0: reactions, posters, Seerr webhook

- Admins can react 👍/ or 👎/ on a !media request to approve/decline
- Posters auto-attach to request confirmations and !media random picks
  (TMDB for Seerr items, Emby /Items/{id}/Images/Primary for library items)
- New @web.post(/seerr-webhook) handler — Seerr → Matrix room directly,
  replaces the Telegram bridge path
- New config: posters_enabled, admin_users, notifications_room,
  seerr_webhook_secret
This commit is contained in:
Maddox 2026-04-28 18:16:54 -04:00
parent bec9a1b8e7
commit efa22b4e25
5 changed files with 234 additions and 4 deletions

View file

@ -3,6 +3,21 @@
http_timeout: 15 http_timeout: 15
default_results: 5 default_results: 5
# Inline poster art on `!media request` confirmations and `!media random` picks.
posters_enabled: true
# Matrix users allowed to approve/decline Seerr requests via 👍/👎 reactions.
admin_users:
- "@admin:example.com"
# Where to post Seerr webhook notifications (request created/approved/available/etc).
# Must be a room the bot has joined. Leave empty to disable.
notifications_room: ""
# Shared secret Seerr must send as `Authorization: Bearer <secret>` header.
# Generate with `openssl rand -hex 32`. Empty = no auth check (NOT recommended).
seerr_webhook_secret: ""
# --- Service endpoints --- # --- Service endpoints ---
seerr: seerr:

View file

@ -1,6 +1,6 @@
maubot: 0.3.1 maubot: 0.3.1
id: com.3ddbrewery.media id: com.3ddbrewery.media
version: 0.2.0 version: 0.3.0
license: MIT license: MIT
modules: modules:
- media_bot - media_bot

View file

@ -15,9 +15,20 @@ from datetime import date, datetime, timedelta, timezone
from typing import Any, Optional, Type from typing import Any, Optional, Type
import aiohttp import aiohttp
from aiohttp.web import Request, Response, json_response
from maubot import MessageEvent, Plugin from maubot import MessageEvent, Plugin
from maubot.handlers import command from maubot.handlers import command, event, web
from mautrix.types import (
EventType,
Format,
ImageInfo,
MediaMessageEventContent,
MessageType,
ReactionEvent,
RoomID,
TextMessageEventContent,
)
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from .clients.arr import ArrError, RadarrClient, SonarrClient from .clients.arr import ArrError, RadarrClient, SonarrClient
@ -62,6 +73,10 @@ class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("http_timeout") helper.copy("http_timeout")
helper.copy("default_results") helper.copy("default_results")
helper.copy("posters_enabled")
helper.copy("admin_users")
helper.copy("notifications_room")
helper.copy("seerr_webhook_secret")
helper.copy("seerr") helper.copy("seerr")
helper.copy("sonarr") helper.copy("sonarr")
helper.copy("radarr") helper.copy("radarr")
@ -71,6 +86,15 @@ class Config(BaseProxyConfig):
helper.copy("user_map") helper.copy("user_map")
# Reactions counted as approve/decline. Skin-tone variants pass via startswith.
APPROVE_KEYS = ("👍", "", "🟢")
DECLINE_KEYS = ("👎", "", "🟥")
def _matches(key: str, candidates: tuple[str, ...]) -> bool:
return any(key.startswith(c) for c in candidates)
class MediaBot(Plugin): class MediaBot(Plugin):
config: Config config: Config
session: aiohttp.ClientSession session: aiohttp.ClientSession
@ -87,6 +111,10 @@ class MediaBot(Plugin):
# restart clears it (acceptable, results are cheap to rebuild). # restart clears it (acceptable, results are cheap to rebuild).
_search_cache: dict[tuple[str, str], list[dict]] _search_cache: dict[tuple[str, str], list[dict]]
# Map of bot-message event_id → Seerr request_id, for reaction-based
# approve/decline. Capped at 200; oldest entries are evicted on overflow.
_pending_requests: dict[str, int]
async def start(self) -> None: async def start(self) -> None:
self.config.load_and_update() self.config.load_and_update()
timeout = aiohttp.ClientTimeout(total=self.config["http_timeout"]) timeout = aiohttp.ClientTimeout(total=self.config["http_timeout"])
@ -104,6 +132,7 @@ class MediaBot(Plugin):
self.qbt = QbtClient(self.session, q["url"], q["username"], q["password"]) self.qbt = QbtClient(self.session, q["url"], q["username"], q["password"])
self._search_cache = {} self._search_cache = {}
self._pending_requests = {}
self.log.info("Media bot started — users=%d", len(self.config["user_map"] or {})) self.log.info("Media bot started — users=%d", len(self.config["user_map"] or {}))
async def stop(self) -> None: async def stop(self) -> None:
@ -141,6 +170,36 @@ class MediaBot(Plugin):
"""Cache search/trending results so the user can `!media request <N>`.""" """Cache search/trending results so the user can `!media request <N>`."""
self._search_cache[(evt.room_id, evt.sender)] = list(results) self._search_cache[(evt.room_id, evt.sender)] = list(results)
def _track_request(self, event_id: str, request_id: int) -> None:
self._pending_requests[event_id] = request_id
if len(self._pending_requests) > 200:
oldest = next(iter(self._pending_requests))
del self._pending_requests[oldest]
async def _post_poster(self, room_id: str, image_url: str, caption: str) -> bool:
"""Download a poster URL, upload to Matrix, send as m.image. Returns True on success."""
if not self.config["posters_enabled"]:
return False
try:
async with self.session.get(image_url) as r:
if r.status >= 400:
self.log.info("poster fetch %s%s", image_url, r.status)
return False
data = await r.read()
mime = r.headers.get("Content-Type", "image/jpeg").split(";")[0].strip()
mxc = await self.client.upload_media(data, mime_type=mime)
content = MediaMessageEventContent(
msgtype=MessageType.IMAGE,
body=caption,
url=mxc,
info=ImageInfo(mimetype=mime, size=len(data)),
)
await self.client.send_message(RoomID(room_id), content)
return True
except Exception as ex:
self.log.warning("poster upload failed: %s", ex)
return False
# ---------- top-level command ---------- # ---------- top-level command ----------
@command.new("media", aliases=["m"], help="Media stack bot — try `!media help`", @command.new("media", aliases=["m"], help="Media stack bot — try `!media help`",
@ -175,7 +234,8 @@ class MediaBot(Plugin):
"- `!media activity` — current downloads\n" "- `!media activity` — current downloads\n"
"- `!media completed` — finished in the last 24h\n" "- `!media completed` — finished in the last 24h\n"
"- `!media speed` — aggregate down/up speed\n" "- `!media speed` — aggregate down/up speed\n"
"- `!media pause` / `!media unpause` — global pause/resume\n" "- `!media pause` / `!media unpause` — global pause/resume\n\n"
"_Admins can react 👍/👎 on a request to approve/decline._"
) )
await self._say(evt, msg) await self._say(evt, msg)
@ -277,7 +337,19 @@ class MediaBot(Plugin):
return return
status_id = (req or {}).get("status") status_id = (req or {}).get("status")
status = {1: "pending approval", 2: "approved", 3: "declined"}.get(status_id, str(status_id)) status = {1: "pending approval", 2: "approved", 3: "declined"}.get(status_id, str(status_id))
await self._say(evt, f"Requested **{title}** ({media_type}) — status: *{status}*.") request_id = (req or {}).get("id")
# Optional poster preview
poster = SeerrClient.poster_url(item)
if poster:
await self._post_poster(evt.room_id, poster, f"{title} ({media_type})")
msg = f"Requested **{title}** ({media_type}) — status: *{status}*."
if status_id == 1 and request_id:
msg += "\n\n_Admins: react 👍 to approve or 👎 to decline._"
sent_id = await evt.respond(f"{evt.sender}\n\n{msg}")
if request_id and status_id == 1 and sent_id:
self._track_request(sent_id, request_id)
@media.subcommand("requests", help="Show your pending/processing Seerr requests") @media.subcommand("requests", help="Show your pending/processing Seerr requests")
async def cmd_requests(self, evt: MessageEvent) -> None: async def cmd_requests(self, evt: MessageEvent) -> None:
@ -513,6 +585,9 @@ class MediaBot(Plugin):
overview = (item.get("Overview") or "").strip() overview = (item.get("Overview") or "").strip()
if len(overview) > 240: if len(overview) > 240:
overview = overview[:240].rsplit(" ", 1)[0] + "" overview = overview[:240].rsplit(" ", 1)[0] + ""
poster = self.emby.poster_url(item)
if poster:
await self._post_poster(evt.room_id, poster, item.get("Name") or "poster")
msg = f"**Random pick:** {line}" msg = f"**Random pick:** {line}"
if overview: if overview:
msg += f"\n\n{overview}" msg += f"\n\n{overview}"
@ -802,3 +877,115 @@ class MediaBot(Plugin):
f"qBittorrent: {'resumed ▶' if qbt_ok else f'failed ({results[1]})'}" f"qBittorrent: {'resumed ▶' if qbt_ok else f'failed ({results[1]})'}"
) )
await self._say(evt, msg) await self._say(evt, msg)
# ---------- Reactions: admin approve/decline ----------
@event.on(EventType.REACTION)
async def on_reaction(self, evt: ReactionEvent) -> None:
relates = getattr(evt.content, "relates_to", None)
if not relates or not relates.event_id or not relates.key:
return
request_id = self._pending_requests.get(relates.event_id)
if not request_id:
return # not one of our request messages
admins = self.config["admin_users"] or []
if evt.sender not in admins:
self.log.info("Reaction from non-admin %s ignored", evt.sender)
return
key = relates.key
if _matches(key, APPROVE_KEYS):
label, fn = "approved ✅", self.seerr.approve
elif _matches(key, DECLINE_KEYS):
label, fn = "declined ❌", self.seerr.decline
else:
return # unrelated reaction
try:
await fn(request_id)
except SeerrError as ex:
await self.client.send_text(evt.room_id, text=f"Request {request_id} action failed: {ex}")
return
self._pending_requests.pop(relates.event_id, None)
await self.client.send_text(
evt.room_id, text=f"Request {request_id} {label} (by {evt.sender})"
)
# ---------- Webhook: Seerr → Matrix notifications ----------
@web.post("/seerr-webhook")
async def seerr_webhook(self, req: Request) -> Response:
secret = self.config["seerr_webhook_secret"] or ""
if secret:
auth = req.headers.get("Authorization", "")
if auth != f"Bearer {secret}":
self.log.warning("Seerr webhook bad auth from %s", req.remote)
return Response(status=401, text="unauthorized")
try:
payload = await req.json()
except Exception:
return Response(status=400, text="invalid json")
room = (self.config["notifications_room"] or "").strip()
if not room:
return json_response({"ok": False, "error": "notifications_room not configured"})
text, html = self._format_seerr_event(payload)
try:
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=text,
format=Format.HTML,
formatted_body=html,
)
await self.client.send_message(RoomID(room), content)
except Exception as ex:
self.log.exception("Failed to post Seerr webhook to %s", room)
return json_response({"ok": False, "error": str(ex)}, status=500)
poster = payload.get("image")
if poster and self.config["posters_enabled"]:
await self._post_poster(room, poster, payload.get("subject") or "poster")
return json_response({"ok": True})
def _format_seerr_event(self, p: dict) -> tuple[str, str]:
nt = (p.get("notification_type") or "").upper()
subject = p.get("subject") or "?"
message = p.get("message") or ""
media = p.get("media") or {}
request = p.get("request") or {}
media_type = media.get("media_type") or "?"
requester = request.get("requestedBy_username") or ""
emoji = {
"MEDIA_PENDING": "📥",
"MEDIA_APPROVED": "",
"MEDIA_AUTO_APPROVED": "",
"MEDIA_AVAILABLE": "🎬",
"MEDIA_DECLINED": "",
"MEDIA_FAILED": "⚠️",
"ISSUE_CREATED": "🐛",
"ISSUE_COMMENT": "💬",
"ISSUE_RESOLVED": "🛠",
"TEST_NOTIFICATION": "🔧",
}.get(nt, "📣")
label = nt.replace("_", " ").title() or "Notification"
text_parts = [f"{emoji} {label}{subject}"]
if media_type and media_type != "?":
text_parts.append(f"({media_type})")
if requester:
text_parts.append(f"— requested by {requester}")
text = " ".join(text_parts)
if message:
text += f"\n\n{message}"
html = (
f"{emoji} <strong>{label}</strong> — <em>{subject}</em>"
+ (f" ({media_type})" if media_type != '?' else "")
+ (f" — requested by <strong>{requester}</strong>" if requester else "")
+ (f"<br/><br/>{message}" if message else "")
)
return text, html

View file

@ -85,3 +85,15 @@ class EmbyClient:
data = await self._get(f"/Users/{user_id}/Items", params=params) data = await self._get(f"/Users/{user_id}/Items", params=params)
items = (data or {}).get("Items", []) if isinstance(data, dict) else (data or []) items = (data or {}).get("Items", []) if isinstance(data, dict) else (data or [])
return items[0] if items else None return items[0] if items else None
def poster_url(self, item: dict, max_width: int = 500) -> str | None:
"""Build a primary-image URL for an Emby item."""
item_id = item.get("Id")
if not item_id:
return None
# If the item has an ImageTags.Primary, the cached/CDN URL works without auth;
# otherwise fall back to the api_key form.
tag = (item.get("ImageTags") or {}).get("Primary")
if tag:
return f"{self.base}/Items/{item_id}/Images/Primary?tag={tag}&maxWidth={max_width}"
return f"{self.base}/Items/{item_id}/Images/Primary?api_key={self.api_key}&maxWidth={max_width}"

View file

@ -9,6 +9,8 @@ Docs reference (Overseerr-compatible):
from __future__ import annotations from __future__ import annotations
from typing import Optional
import aiohttp import aiohttp
@ -55,3 +57,17 @@ class SeerrClient:
async def trending(self) -> list[dict]: async def trending(self) -> list[dict]:
data = await self._get("/api/v1/discover/trending") data = await self._get("/api/v1/discover/trending")
return (data or {}).get("results", []) if isinstance(data, dict) else (data or []) return (data or {}).get("results", []) if isinstance(data, dict) else (data or [])
async def approve(self, request_id: int) -> dict:
return await self._post(f"/api/v1/request/{request_id}/approve", {})
async def decline(self, request_id: int) -> dict:
return await self._post(f"/api/v1/request/{request_id}/decline", {})
@staticmethod
def poster_url(item: dict, size: str = "w500") -> Optional[str]:
"""Build a TMDB poster URL from a Seerr search/result item."""
path = item.get("posterPath") or item.get("backdropPath")
if not path:
return None
return f"https://image.tmdb.org/t/p/{size}{path}"