diff --git a/README.md b/README.md index 5552a8c..0d140c1 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,145 @@ # maubot-media -Maubot plugin: Matrix bot for the homelab media stack — Seerr, Emby, Sonarr, Radarr, NZBGet, qBittorrent. +A [maubot](https://github.com/maubot/maubot) plugin that turns a Matrix room into a control surface for the *arr / Plex-style media stack: + +- **Search & request** via [Jellyseerr / Overseerr](https://docs.jellyseerr.dev/) **or** directly against Sonarr/Radarr. +- **Library + playback** via Emby (`recent`, `nowplaying`, `watched`, `find`, `resume`, `random`). +- **Sonarr / Radarr / Lidarr** queue, calendar, missing, music search/add. +- **Downloads** — NZBGet + qBittorrent activity, completed, speed, pause/resume. +- **Subscriptions** — `!media subscribe `, with a Sonarr webhook pinging the room when new episodes import. +- **Daily digest** posted to a notifications room (recently added, completed, queued, plus an optional Sunday recap pulled from an ntfy topic). +- **Health check** across all seven backing services in one command. + +Each Matrix user is mapped to per-service IDs in plugin config; unmapped senders get a refusal. ## Commands -``` -!media help # command list +```text +!media help # full command list -# Search & request (Seerr) -!media search # top results across movies + tv -!media request # request top hit -!media request --tv # force TV match -!media request --movie # force movie match -!media requests # your pending/processing requests -!media trending # what's trending +# Search & request +!media search # numbered results +!media request [--tv|--movie] +!media request # pick N from the last search/trending +!media requests # (Seerr only) your pending/processing +!media trending # (Seerr only) what's trending # Library & playback (Emby) -!media nowplaying # active sessions -!media recent [movies|tv] # recently added (default: both) -!media watched # what you recently finished +!media nowplaying +!media recent [movies|tv] +!media watched +!media find +!media resume +!media random [movie|tv] -# Downloads (Sonarr/Radarr/NZBGet/qBittorrent) -!media queue # combined Sonarr + Radarr queue -!media activity # NZBGet + qBt — current downloads -!media upcoming # Sonarr calendar, next 7 days -!media missing # Sonarr wanted/missing +# Sonarr / Radarr / Lidarr +!media queue +!media upcoming +!media missing +!media music +!media music add +!media health + +# Downloads (NZBGet + qBittorrent) +!media activity +!media completed +!media speed +!media pause / unpause + +# Subscriptions / digest +!media subscribe +!media unsubscribe +!media subscriptions +!media digest # fire today's digest now ``` -## How it works +## Requirements -The plugin runs in maubot on `im` (Hetzner) and calls each service over Tailscale: +- maubot **0.3.1+** running somewhere reachable from your Matrix server. +- A Matrix user for the bot. Provision via `/_synapse/admin/v1/register` (Synapse) or your homeserver's equivalent, then log in once to capture an `access_token` + `device_id` for the maubot client config. +- At minimum: Sonarr **and** Radarr (or just one, with the other left as the placeholder URL — the missing one will fail searches for that media type). +- Optional: Seerr/Jellyseerr/Overseerr, Emby, NZBGet, qBittorrent, Lidarr. -| Service | URL | -|---------|-----| -| Seerr | `http://arr-host:5056` | -| Sonarr | `http://arr-host:8989` | -| Radarr | `http://arr-host:7878` | -| Emby | `http://emby-host:8096/emby` | -| NZBGet | `http://download-host:6789` | -| qBittorrent | `http://download-host:8082` | - -Each Matrix sender is mapped to per-service user IDs via plugin config (Seerr user ID, Emby user ID). Senders not in the `user_map` are rejected. - -## Build +## Build & install ```bash -cd ~/maubot-media -zip -rq com.3ddbrewery.media-v0.1.0.mbp maubot.yaml base-config.yaml media_bot/ README.md -x '*/__pycache__/*' +git clone https://git.3ddbrewery.com/maddox/maubot-media.git +cd maubot-media + +# Bump the plugin id in maubot.yaml if you fork this — it must be unique per +# maubot instance. The current id (com.3ddbrewery.media) is reserved for the +# upstream deployment. + +PLUGIN_VERSION=$(grep '^version:' maubot.yaml | awk '{print $2}') +zip -rq "com.3ddbrewery.media-v${PLUGIN_VERSION}.mbp" \ + maubot.yaml base-config.yaml media_bot/ README.md LICENSE \ + -x '*/__pycache__/*' ``` -Upload via the maubot web UI at https://matrix.example.com/_matrix/maubot, or use the upload curl flow documented in `docs/media-bot.md`. +Upload `*.mbp` via the maubot UI's *Plugins → upload*, then create an instance pointing at your bot user. -## Config +## Configuration -See `base-config.yaml`. After upload, set real values via the maubot UI's instance config tab. +All config lives in the maubot UI's *instance config* tab; the defaults shipped in `base-config.yaml` use placeholder hostnames (`http://sonarr:8989`, etc.) and `CHANGEME` API keys. + +### Required + +```yaml +sonarr: + url: http://:8989 + api_key: + +radarr: + url: http://:7878 + api_key: + +user_map: + "@alice:example.com": + seerr_user_id: 1 # only required if Seerr is configured + emby_user_id: "abc123def" # only required for Emby commands +``` + +### Seerr (optional) + +If `seerr.url` and `seerr.api_key` are set, search/request route through Seerr (with its approval workflow + 👍/👎 admin reactions). If left empty, the bot falls back to direct Sonarr/Radarr lookup + add — no approval, no per-user request quotas, but no Seerr install needed either. + +```yaml +seerr: + url: http://:5055 + api_key: +``` + +### Lidarr / Emby / Downloads + +All optional — the corresponding commands fail gracefully when the service isn't reachable. Commands that need them: Lidarr (`!media music`), Emby (`!media nowplaying|recent|watched|find|resume|random`), NZBGet/qBt (`!media activity|completed|speed|pause|unpause` and the daily digest's "completed" section). + +### Webhooks (optional) + +Two endpoints are exposed when the plugin's `webapp` is enabled (default): + +| URL | Purpose | +|---|---| +| `/_matrix/maubot/plugin//seerr-webhook` | Seerr → Matrix notifications (request created/approved/available/etc) | +| `/_matrix/maubot/plugin//sonarr-webhook` | Sonarr Download events → ping subscribers | + +Configure them in Seerr/Sonarr's *Settings → Notifications/Connect → Webhook* and set `Authorization: Bearer ` headers. The shared secret is `seerr_webhook_secret` / `sonarr_webhook_secret` in the instance config; leave empty to disable auth (not recommended). + +### Notifications room + digest + +```yaml +notifications_room: "!roomid:example.com" # bot must be joined +digest_enabled: true +digest_hour: 8 # local hour, 24h +``` + +The Sunday digest can also pull a recap from an ntfy topic (e.g. an external library-cleaner job that posts its summary). Set `ntfy_url` to enable. + +## Notes for forkers + +- The plugin id (`com.3ddbrewery.media`) is unique per maubot deployment — change it in `maubot.yaml` before building if you're running this alongside the upstream instance. +- No personal data is stored *in* the plugin: every URL, key, and Matrix-ID lives in the instance config you set in the maubot UI. +- The plugin's SQLite tables (`subscriptions`, `digest_state`) are managed via maubot's `UpgradeTable`; nothing manual needed. + +## License + +MIT — see `LICENSE`. diff --git a/base-config.yaml b/base-config.yaml index a5c930d..421190b 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,4 +1,8 @@ # Media bot configuration +# +# After uploading the plugin, set real values via the maubot UI's +# "instance config" tab. Anything left at the placeholder values below +# disables the related feature. http_timeout: 15 default_results: 5 @@ -7,82 +11,103 @@ default_results: 5 posters_enabled: true # Matrix users allowed to approve/decline Seerr requests via 👍/👎 reactions. -admin_users: - - "@admin:example.com" +admin_users: [] -# Where to post Seerr webhook notifications (request created/approved/available/etc). +# Where to post Seerr/Sonarr webhook notifications and the daily digest. # 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). +# Shared secret Seerr must send as `Authorization: Bearer ` header +# when calling the seerr-webhook endpoint. Generate with `openssl rand -hex 32`. +# Empty = no auth check (NOT recommended). seerr_webhook_secret: "" # Sonarr → Matrix subscription notifications. # Configure Sonarr → Settings → Connect → Webhook with URL -# https://matrix.example.com/_matrix/maubot/plugin/media/sonarr-webhook +# https:///_matrix/maubot/plugin//sonarr-webhook # and Headers: `Authorization: Bearer `. Triggers on Download events. sonarr_webhook_secret: "" # Daily digest — fires once a day in `notifications_room`. digest_enabled: true -digest_hour: 8 # local hour (Indianapolis), 0-23 +digest_hour: 8 # local hour, 0-23 -# Sunday digest also pulls the emby-cleaner recap from ntfy. -# The cleaner runs Sunday 4:20 AM (cron on control); digest fires after. -ntfy_url: "https://ntfy.example.com" +# Optional Sunday recap pulled from an ntfy topic (e.g. an external cleanup job +# posts its summary there). Leave ntfy_url empty to skip. +ntfy_url: "" emby_cleaner_topic: "emby-cleaner" # --- Service endpoints --- - +# +# Seerr is OPTIONAL. If url/api_key are left as placeholders, `!media search` +# and `!media request` route directly to Sonarr (TV) and Radarr (movies) +# instead. The `!media requests` and `!media trending` commands require Seerr +# and will return a hint when it isn't configured. seerr: - url: http://arr-host:5056 - api_key: CHANGEME + url: "" + api_key: "" +# Sonarr / Radarr / Lidarr — defaults below auto-pick the first quality +# profile + root folder Lidarr/Sonarr/Radarr report. Override here to pin +# specific ones. sonarr: - url: http://arr-host:8989 + url: http://sonarr:8989 api_key: CHANGEME + quality_profile_id: null # auto-pick first if null + language_profile_id: null # auto-pick first; Sonarr v3 only (v4 ignores) + root_folder_path: null # e.g. "/tv" + monitor: "all" # all|future|missing|existing|firstSeason|latestSeason|none + search_on_add: true radarr: - url: http://arr-host:7878 + url: http://radarr:7878 api_key: CHANGEME + quality_profile_id: null + root_folder_path: null # e.g. "/movies" + monitor: "movieOnly" + minimum_availability: "released" # announced|inCinemas|released + search_on_add: true # Lidarr — music. API v1 (different from Sonarr/Radarr v3). -# Profile/folder defaults: leave null to auto-pick the first one Lidarr returns. lidarr: - url: http://arr-host:8686 + url: http://lidarr:8686 api_key: CHANGEME - quality_profile_id: null # e.g. 3 for "Standard" - metadata_profile_id: null # e.g. 1 for "Standard" - root_folder_path: null # e.g. "/media/Music" - monitor: "all" # all|future|missing|existing|first|latest|none - search_on_add: true # kick off a search for missing albums right after add + quality_profile_id: null + metadata_profile_id: null + root_folder_path: null # e.g. "/music" + monitor: "all" + search_on_add: true emby: - url: http://emby-host:8096/emby + url: http://emby:8096/emby api_key: CHANGEME nzbget: - url: http://download-host:6789 + url: http://nzbget:6789 username: nzbget password: CHANGEME qbittorrent: - url: http://download-host:8082 + url: http://qbittorrent:8080 username: admin password: CHANGEME # Map Matrix user IDs to per-service user identifiers. # Senders not in this map get an "unauthorized" reply. # +# Per-user fields: +# seerr_user_id: int — required for `!media request` when Seerr is enabled +# emby_user_id: str — required for Emby commands (recent/watched/etc) +# # Optional per-user defaults: # default_media_type: "movie" | "tv" — used by `!media request ` when no # --tv/--movie flag is given # result_count: int — overrides default_results above -user_map: - "@admin:example.com": - seerr_user_id: 1 - emby_user_id: "00000000000000000000000000000000" - "@user:example.com": - seerr_user_id: 2 - emby_user_id: "TBD" +# +# Example (uncomment and edit): +# user_map: +# "@alice:example.com": +# seerr_user_id: 1 +# emby_user_id: "abc123def456" +# default_media_type: "movie" +user_map: {} diff --git a/maubot.yaml b/maubot.yaml index f016b7a..f22d8ee 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.3.1 id: com.3ddbrewery.media -version: 0.5.4 +version: 0.6.0 license: MIT modules: - media_bot diff --git a/media_bot/bot.py b/media_bot/bot.py index ea2f1f8..8894202 100644 --- a/media_bot/bot.py +++ b/media_bot/bot.py @@ -169,6 +169,10 @@ class MediaBot(Plugin): self.nzbget = NzbgetClient(self.session, n["url"], n["username"], n["password"]) self.qbt = QbtClient(self.session, q["url"], q["username"], q["password"]) + # Seerr is optional. When disabled, search/request fall back to direct + # Sonarr/Radarr lookup+add, and the seerr-only commands return a hint. + self.seerr_enabled = self._service_configured(s) + self._search_cache = {} self._pending_requests = {} await self._ensure_db_schema() @@ -189,6 +193,16 @@ class MediaBot(Plugin): # ---------- helpers ---------- + @staticmethod + def _service_configured(block: dict | None) -> bool: + """A service block is 'configured' if it has a non-empty url and a + non-placeholder api_key. Used to gate optional integrations.""" + if not block: + return False + url = (block.get("url") or "").strip() + key = (block.get("api_key") or "").strip() + return bool(url) and key not in ("", "CHANGEME") + def _resolve_user(self, sender: str) -> Optional[dict]: return (self.config["user_map"] or {}).get(sender) @@ -256,12 +270,16 @@ class MediaBot(Plugin): await evt.mark_read() msg = ( "**Media bot commands**\n\n" - "*Search & request (Seerr)*\n" + "*Search & request*\n" "- `!media search ` — top results (numbered)\n" "- `!media request [--tv|--movie]` — request top hit\n" "- `!media request ` — pick item N from your last search/trending\n" - "- `!media requests` — your pending/processing requests\n" - "- `!media trending` — what's trending (numbered)\n\n" + + ("- `!media requests` — your pending/processing requests\n" + "- `!media trending` — what's trending (numbered)\n\n" + if self.seerr_enabled else + "_(Seerr not configured — search/request go directly to Sonarr+Radarr; " + "no approval workflow.)_\n\n") + + "*Library (Emby)*\n" "- `!media nowplaying` — active sessions\n" "- `!media recent [movies|tv]` — recently added\n" @@ -299,14 +317,19 @@ class MediaBot(Plugin): await evt.mark_read() if await self._reject_unmapped(evt): return - try: - results = await self.seerr.search(query) - except SeerrError as ex: - await self._say(evt, f"Search failed: {ex}") - return - results = [r for r in results if r.get("mediaType") in ("movie", "tv")] + if self.seerr_enabled: + try: + results = await self.seerr.search(query) + except SeerrError as ex: + await self._say(evt, f"Search failed: {ex}") + return + results = [r for r in results if r.get("mediaType") in ("movie", "tv")] + for r in results: + r["_source"] = "seerr" + else: + results = await self._arr_search(query) if not results: - await self._say(evt, f"No Seerr results for **{query}**.") + await self._say(evt, f"No results for **{query}**.") return n = self._result_count(evt.sender) shown = results[:n] @@ -327,8 +350,8 @@ class MediaBot(Plugin): return user_cfg = self._resolve_user(evt.sender) or {} seerr_uid = user_cfg.get("seerr_user_id") - if not seerr_uid: - await self._say(evt, "Your Matrix user has no Seerr user_id mapped — ask maddox.") + if self.seerr_enabled and not seerr_uid: + await self._say(evt, "Your Matrix user has no `seerr_user_id` mapped in `user_map`.") return # Numbered selection: !media request 2 → 2nd item from last search/trending @@ -342,8 +365,7 @@ class MediaBot(Plugin): if idx < 0 or idx >= len(cached): await self._say(evt, f"Pick a number 1-{len(cached)}.") return - top = cached[idx] - await self._do_request(evt, seerr_uid, top) + await self._do_request(evt, seerr_uid, cached[idx]) return # Free-text path: optional --tv/--movie flag, else fall back to user default @@ -362,12 +384,17 @@ class MediaBot(Plugin): if not q: await self._say(evt, "Need a search query, e.g. `!media request dune --movie`.") return - try: - results = await self.seerr.search(q) - except SeerrError as ex: - await self._say(evt, f"Search failed: {ex}") - return - candidates = [r for r in results if r.get("mediaType") in ("movie", "tv")] + if self.seerr_enabled: + try: + results = await self.seerr.search(q) + except SeerrError as ex: + await self._say(evt, f"Search failed: {ex}") + return + candidates = [r for r in results if r.get("mediaType") in ("movie", "tv")] + for r in candidates: + r["_source"] = "seerr" + else: + candidates = await self._arr_search(q, media_type=forced_type) if forced_type: candidates = [r for r in candidates if r.get("mediaType") == forced_type] if not candidates: @@ -375,7 +402,14 @@ class MediaBot(Plugin): return await self._do_request(evt, seerr_uid, candidates[0]) - async def _do_request(self, evt: MessageEvent, seerr_uid: int, item: dict) -> None: + async def _do_request(self, evt: MessageEvent, seerr_uid: Optional[int], item: dict) -> None: + source = item.get("_source") or ("seerr" if self.seerr_enabled else "arr") + if source == "seerr": + await self._do_request_seerr(evt, seerr_uid, item) + else: + await self._do_request_arr(evt, item) + + async def _do_request_seerr(self, evt: MessageEvent, seerr_uid: Optional[int], item: dict) -> None: media_type = item.get("mediaType") tmdb_id = item.get("id") title = item.get("title") or item.get("name") or "?" @@ -403,15 +437,157 @@ class MediaBot(Plugin): if request_id and status_id == 1 and sent_id: self._track_request(sent_id, request_id) + async def _do_request_arr(self, evt: MessageEvent, item: dict) -> None: + """Add a movie/series directly to Radarr/Sonarr (no Seerr in the loop).""" + media_type = item.get("mediaType") + title = item.get("title") or item.get("name") or "?" + raw = item.get("_raw") or {} + if media_type == "movie": + ok, msg = await self._add_radarr(raw) + elif media_type == "tv": + ok, msg = await self._add_sonarr(raw) + else: + await self._say(evt, f"**{title}** is neither movie nor tv — can't add.") + return + + # Poster preview if Radarr/Sonarr returned one in the lookup payload + poster = next( + (img.get("remoteUrl") or img.get("url") + for img in (raw.get("images") or []) + if img.get("coverType") == "poster"), + None, + ) + if poster: + await self._post_poster(evt.room_id, poster, f"{title} ({media_type})") + + prefix = "Added" if ok else "Failed" + await self._say(evt, f"{prefix} **{title}** ({media_type}) — {msg}") + + async def _add_sonarr(self, series: dict) -> tuple[bool, str]: + cfg = self.config["sonarr"] or {} + try: + qpid = cfg.get("quality_profile_id") + root = cfg.get("root_folder_path") + if not qpid: + qps = await self.sonarr.quality_profiles() + qpid = qps[0]["id"] if qps else None + if not root: + rfs = await self.sonarr.root_folders() + root = rfs[0]["path"] if rfs else None + lpid = cfg.get("language_profile_id") + if lpid is None: + lps = await self.sonarr.language_profiles() + lpid = lps[0]["id"] if lps else None + except ArrError as ex: + return False, f"Sonarr defaults lookup failed: {ex}" + if not (qpid and root): + return False, "Sonarr is missing a quality profile or root folder." + + payload = dict(series) + payload.update({ + "qualityProfileId": qpid, + "rootFolderPath": root, + "monitored": True, + "seasonFolder": True, + "addOptions": { + "monitor": cfg.get("monitor", "all"), + "searchForMissingEpisodes": bool(cfg.get("search_on_add", True)), + "searchForCutoffUnmetEpisodes": False, + }, + }) + if lpid: # Sonarr v3 only + payload["languageProfileId"] = lpid + try: + await self.sonarr.add_series(payload) + except ArrError as ex: + return False, f"Sonarr add failed: {ex}" + searched = "search kicked off" if cfg.get("search_on_add", True) else "no search triggered" + return True, f"Sonarr profile {qpid}, root `{root}`, {searched}." + + async def _add_radarr(self, movie: dict) -> tuple[bool, str]: + cfg = self.config["radarr"] or {} + try: + qpid = cfg.get("quality_profile_id") + root = cfg.get("root_folder_path") + if not qpid: + qps = await self.radarr.quality_profiles() + qpid = qps[0]["id"] if qps else None + if not root: + rfs = await self.radarr.root_folders() + root = rfs[0]["path"] if rfs else None + except ArrError as ex: + return False, f"Radarr defaults lookup failed: {ex}" + if not (qpid and root): + return False, "Radarr is missing a quality profile or root folder." + + payload = dict(movie) + payload.update({ + "qualityProfileId": qpid, + "rootFolderPath": root, + "monitored": True, + "minimumAvailability": cfg.get("minimum_availability", "released"), + "addOptions": { + "monitor": cfg.get("monitor", "movieOnly"), + "searchForMovie": bool(cfg.get("search_on_add", True)), + }, + }) + try: + await self.radarr.add_movie(payload) + except ArrError as ex: + return False, f"Radarr add failed: {ex}" + searched = "search kicked off" if cfg.get("search_on_add", True) else "no search triggered" + return True, f"Radarr profile {qpid}, root `{root}`, {searched}." + + async def _arr_search(self, query: str, media_type: Optional[str] = None) -> list[dict]: + """Direct Sonarr+Radarr lookup, normalized to the Seerr search-item shape + so the rest of the bot (formatting, caching, request flow) doesn't care + which source produced the result.""" + tasks = [] + if media_type != "movie": + tasks.append(("tv", self.sonarr.lookup(query))) + if media_type != "tv": + tasks.append(("movie", self.radarr.lookup(query))) + results: list[dict] = [] + gathered = await asyncio.gather( + *(t[1] for t in tasks), return_exceptions=True, + ) + for (mt, _), data in zip(tasks, gathered): + if isinstance(data, Exception): + self.log.warning("arr lookup failed for %s: %s", mt, data) + continue + for item in data: + results.append(self._normalize_arr_item(item, mt)) + return results + + @staticmethod + def _normalize_arr_item(item: dict, media_type: str) -> dict: + """Reshape a Sonarr/Radarr lookup result so it slots into the same code + paths as a Seerr search result.""" + year = item.get("year") + date_field = "releaseDate" if media_type == "movie" else "firstAirDate" + return { + "title": item.get("title") or item.get("name") or "?", + "mediaType": media_type, + date_field: f"{year}-01-01" if year else "", + "id": item.get("tmdbId") if media_type == "movie" else item.get("tvdbId"), + "tmdbId": item.get("tmdbId"), + "tvdbId": item.get("tvdbId"), + "_source": "radarr" if media_type == "movie" else "sonarr", + "_raw": item, + } + @media.subcommand("requests", help="Show your pending/processing Seerr requests") async def cmd_requests(self, evt: MessageEvent) -> None: await evt.mark_read() if await self._reject_unmapped(evt): return + if not self.seerr_enabled: + await self._say(evt, "Seerr isn't configured — `!media requests` is unavailable.") + return user_cfg = self._resolve_user(evt.sender) seerr_uid = (user_cfg or {}).get("seerr_user_id") if not seerr_uid: - await self._say(evt, "Your Matrix user has no Seerr user_id mapped — ask maddox.") + await self._say(evt, "Your Matrix user has no `seerr_user_id` mapped in `user_map`.") return try: reqs = await self.seerr.user_requests(seerr_uid, take=10) @@ -435,6 +611,9 @@ class MediaBot(Plugin): await evt.mark_read() if await self._reject_unmapped(evt): return + if not self.seerr_enabled: + await self._say(evt, "Seerr isn't configured — `!media trending` is unavailable.") + return try: results = await self.seerr.trending() except SeerrError as ex: @@ -445,6 +624,8 @@ class MediaBot(Plugin): if not results: await self._say(evt, "Nothing trending right now.") return + for r in results: + r["_source"] = "seerr" self._stash_search(evt, results) lines = [f"**Trending ({len(results)}):**"] for i, r in enumerate(results, 1): @@ -543,7 +724,7 @@ class MediaBot(Plugin): user_cfg = self._resolve_user(evt.sender) or {} emby_uid = user_cfg.get("emby_user_id") if not emby_uid or emby_uid == "TBD": - await self._say(evt, "Your Matrix user has no Emby user_id mapped — ask maddox.") + await self._say(evt, "Your Matrix user has no `emby_user_id` mapped in `user_map`.") return try: items = await self.emby.user_played(emby_uid, limit=10) @@ -589,7 +770,7 @@ class MediaBot(Plugin): user_cfg = self._resolve_user(evt.sender) or {} emby_uid = user_cfg.get("emby_user_id") if not emby_uid or emby_uid == "TBD": - await self._say(evt, "Your Matrix user has no Emby user_id mapped — ask maddox.") + await self._say(evt, "Your Matrix user has no `emby_user_id` mapped in `user_map`.") return try: items = await self.emby.resume(emby_uid, limit=10) diff --git a/media_bot/clients/arr.py b/media_bot/clients/arr.py index 58b7182..2bb6d30 100644 --- a/media_bot/clients/arr.py +++ b/media_bot/clients/arr.py @@ -69,9 +69,45 @@ class SonarrClient(ArrClient): data = await self._get("/api/v3/wanted/missing", params=params) return (data or {}).get("records", []) if isinstance(data, dict) else (data or []) + async def lookup(self, term: str) -> list[dict]: + data = await self._get("/api/v3/series/lookup", params={"term": term}) + return data if isinstance(data, list) else (data or []) + + async def quality_profiles(self) -> list[dict]: + data = await self._get("/api/v3/qualityprofile") + return data if isinstance(data, list) else [] + + async def language_profiles(self) -> list[dict]: + # Sonarr v4 dropped these; v3 still has them. Empty list = skip the field. + try: + data = await self._get("/api/v3/languageprofile") + return data if isinstance(data, list) else [] + except ArrError: + return [] + + async def root_folders(self) -> list[dict]: + data = await self._get("/api/v3/rootfolder") + return data if isinstance(data, list) else [] + + async def add_series(self, series: dict) -> dict: + return await self._post("/api/v3/series", series) # type: ignore[return-value] + class RadarrClient(ArrClient): - pass + async def lookup(self, term: str) -> list[dict]: + data = await self._get("/api/v3/movie/lookup", params={"term": term}) + return data if isinstance(data, list) else (data or []) + + async def quality_profiles(self) -> list[dict]: + data = await self._get("/api/v3/qualityprofile") + return data if isinstance(data, list) else [] + + async def root_folders(self) -> list[dict]: + data = await self._get("/api/v3/rootfolder") + return data if isinstance(data, list) else [] + + async def add_movie(self, movie: dict) -> dict: + return await self._post("/api/v3/movie", movie) # type: ignore[return-value] class LidarrClient(ArrClient):