diff --git a/base-config.yaml b/base-config.yaml index be35b35..31a774e 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -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 ` header. +# Generate with `openssl rand -hex 32`. Empty = no auth check (NOT recommended). +seerr_webhook_secret: "" + # --- Service endpoints --- seerr: diff --git a/maubot.yaml b/maubot.yaml index 94997e8..e666aad 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -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 diff --git a/media_bot/bot.py b/media_bot/bot.py index 28b193f..1738ac3 100644 --- a/media_bot/bot.py +++ b/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 `.""" 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} {label}{subject}" + + (f" ({media_type})" if media_type != '?' else "") + + (f" — requested by {requester}" if requester else "") + + (f"

{message}" if message else "") + ) + return text, html diff --git a/media_bot/clients/emby.py b/media_bot/clients/emby.py index 3346ea8..2666bc8 100644 --- a/media_bot/clients/emby.py +++ b/media_bot/clients/emby.py @@ -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}" diff --git a/media_bot/clients/seerr.py b/media_bot/clients/seerr.py index 18fc769..f21bf3c 100644 --- a/media_bot/clients/seerr.py +++ b/media_bot/clients/seerr.py @@ -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}"