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:
parent
bec9a1b8e7
commit
efa22b4e25
5 changed files with 234 additions and 4 deletions
|
|
@ -3,6 +3,21 @@
|
|||
http_timeout: 15
|
||||
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 ---
|
||||
|
||||
seerr:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
maubot: 0.3.1
|
||||
id: com.3ddbrewery.media
|
||||
version: 0.2.0
|
||||
version: 0.3.0
|
||||
license: MIT
|
||||
modules:
|
||||
- media_bot
|
||||
|
|
|
|||
193
media_bot/bot.py
193
media_bot/bot.py
|
|
@ -15,9 +15,20 @@ from datetime import date, datetime, timedelta, timezone
|
|||
from typing import Any, Optional, Type
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.web import Request, Response, json_response
|
||||
|
||||
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 .clients.arr import ArrError, RadarrClient, SonarrClient
|
||||
|
|
@ -62,6 +73,10 @@ class Config(BaseProxyConfig):
|
|||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
helper.copy("http_timeout")
|
||||
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("sonarr")
|
||||
helper.copy("radarr")
|
||||
|
|
@ -71,6 +86,15 @@ class Config(BaseProxyConfig):
|
|||
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):
|
||||
config: Config
|
||||
session: aiohttp.ClientSession
|
||||
|
|
@ -87,6 +111,10 @@ class MediaBot(Plugin):
|
|||
# restart clears it (acceptable, results are cheap to rebuild).
|
||||
_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:
|
||||
self.config.load_and_update()
|
||||
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._search_cache = {}
|
||||
self._pending_requests = {}
|
||||
self.log.info("Media bot started — users=%d", len(self.config["user_map"] or {}))
|
||||
|
||||
async def stop(self) -> None:
|
||||
|
|
@ -141,6 +170,36 @@ class MediaBot(Plugin):
|
|||
"""Cache search/trending results so the user can `!media request <N>`."""
|
||||
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 ----------
|
||||
|
||||
@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 completed` — finished in the last 24h\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)
|
||||
|
||||
|
|
@ -277,7 +337,19 @@ class MediaBot(Plugin):
|
|||
return
|
||||
status_id = (req or {}).get("status")
|
||||
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")
|
||||
async def cmd_requests(self, evt: MessageEvent) -> None:
|
||||
|
|
@ -513,6 +585,9 @@ class MediaBot(Plugin):
|
|||
overview = (item.get("Overview") or "").strip()
|
||||
if len(overview) > 240:
|
||||
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}"
|
||||
if overview:
|
||||
msg += f"\n\n{overview}"
|
||||
|
|
@ -802,3 +877,115 @@ class MediaBot(Plugin):
|
|||
f"qBittorrent: {'resumed ▶' if qbt_ok else f'failed ({results[1]})'}"
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -85,3 +85,15 @@ class EmbyClient:
|
|||
data = await self._get(f"/Users/{user_id}/Items", params=params)
|
||||
items = (data or {}).get("Items", []) if isinstance(data, dict) else (data or [])
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ Docs reference (Overseerr-compatible):
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
|
|
@ -55,3 +57,17 @@ class SeerrClient:
|
|||
async def trending(self) -> list[dict]:
|
||||
data = await self._get("/api/v1/discover/trending")
|
||||
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}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue