v0.6.0: Seerr optional, scrubbed personal data, README rewrite

Issue #2.

Search/request now work without Seerr — when seerr.url/api_key are left
as placeholders the bot falls back to direct Sonarr/Radarr lookup + add
(mirrors how Lidarr music already works). Numbered selection keeps
working across both sources via a _source discriminator stamped onto
each result. !media requests / !media trending now print a friendly
hint when Seerr is absent.

base-config.yaml no longer ships any homelab-specific URLs, MXIDs, or
Emby UIDs — admin_users defaults to [], user_map to {}, and every
service URL uses a docker-hostname placeholder. New per-service config
keys (quality_profile_id, root_folder_path, monitor, search_on_add,
language_profile_id, minimum_availability) let operators pin Sonarr/
Radarr defaults the same way Lidarr already could; null = auto-pick
the first profile/folder.

README rewritten as a self-contained setup guide: requirements, build,
upload, instance config (Required vs Optional with the Seerr fallback
called out), webhook setup, fork notes.
This commit is contained in:
Maddox 2026-05-03 15:02:50 -04:00
parent 40a4dccc27
commit 6cfad31dfc
5 changed files with 426 additions and 95 deletions

163
README.md
View file

@ -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 <show>`, 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 <query> # top results across movies + tv
!media request <query> # request top hit
!media request <query> --tv # force TV match
!media request <query> --movie # force movie match
!media requests # your pending/processing requests
!media trending # what's trending
# Search & request
!media search <query> # numbered results
!media request <query> [--tv|--movie]
!media request <N> # 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 <query>
!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 <q>
!media music add <q|N>
!media health
# Downloads (NZBGet + qBittorrent)
!media activity
!media completed
!media speed
!media pause / unpause
# Subscriptions / digest
!media subscribe <show>
!media unsubscribe <show>
!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://192.168.1.80:5056` |
| Sonarr | `http://192.168.1.80:8989` |
| Radarr | `http://192.168.1.80:7878` |
| Emby | `http://192.168.1.120:8096/emby` |
| NZBGet | `http://192.168.1.122:6789` |
| qBittorrent | `http://192.168.1.122: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.fails.me/_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://<sonarr-host>:8989
api_key: <sonarr-api-key>
radarr:
url: http://<radarr-host>:7878
api_key: <radarr-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://<seerr-host>:5055
api_key: <seerr-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/<instance>/seerr-webhook` | Seerr → Matrix notifications (request created/approved/available/etc) |
| `/_matrix/maubot/plugin/<instance>/sonarr-webhook` | Sonarr Download events → ping subscribers |
Configure them in Seerr/Sonarr's *Settings → Notifications/Connect → Webhook* and set `Authorization: Bearer <secret>` 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`.

View file

@ -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:
- "@maddox:fails.me"
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 <secret>` header.
# Generate with `openssl rand -hex 32`. Empty = no auth check (NOT recommended).
# Shared secret Seerr must send as `Authorization: Bearer <secret>` 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.fails.me/_matrix/maubot/plugin/media/sonarr-webhook
# https://<your-maubot>/_matrix/maubot/plugin/<instance>/sonarr-webhook
# and Headers: `Authorization: Bearer <secret>`. 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.3ddbrewery.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://192.168.1.80: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://192.168.1.80: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://192.168.1.80: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://192.168.1.80: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://192.168.1.120:8096/emby
url: http://emby:8096/emby
api_key: CHANGEME
nzbget:
url: http://192.168.1.122:6789
url: http://nzbget:6789
username: nzbget
password: CHANGEME
qbittorrent:
url: http://192.168.1.122: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 <q>` when no
# --tv/--movie flag is given
# result_count: int — overrides default_results above
user_map:
"@maddox:fails.me":
seerr_user_id: 1
emby_user_id: "052e6796e9d94270858e05fb582ba5a6"
"@jess:fails.me":
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: {}

View file

@ -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

View file

@ -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 <query>` — top results (numbered)\n"
"- `!media request <query> [--tv|--movie]` — request top hit\n"
"- `!media request <N>` — 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)

View file

@ -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):