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:
parent
23e4fbd8d7
commit
d90d58755f
5 changed files with 426 additions and 95 deletions
163
README.md
163
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 <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://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://<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`.
|
||||
|
|
|
|||
|
|
@ -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 <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.example.com/_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.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 <q>` 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: {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
203
media_bot/bot.py
203
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 <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 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
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue