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
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue