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
40a4dccc27
commit
6cfad31dfc
5 changed files with 426 additions and 95 deletions
163
README.md
163
README.md
|
|
@ -1,56 +1,145 @@
|
||||||
# maubot-media
|
# 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
|
## Commands
|
||||||
|
|
||||||
```
|
```text
|
||||||
!media help # command list
|
!media help # full command list
|
||||||
|
|
||||||
# Search & request (Seerr)
|
# Search & request
|
||||||
!media search <query> # top results across movies + tv
|
!media search <query> # numbered results
|
||||||
!media request <query> # request top hit
|
!media request <query> [--tv|--movie]
|
||||||
!media request <query> --tv # force TV match
|
!media request <N> # pick N from the last search/trending
|
||||||
!media request <query> --movie # force movie match
|
!media requests # (Seerr only) your pending/processing
|
||||||
!media requests # your pending/processing requests
|
!media trending # (Seerr only) what's trending
|
||||||
!media trending # what's trending
|
|
||||||
|
|
||||||
# Library & playback (Emby)
|
# Library & playback (Emby)
|
||||||
!media nowplaying # active sessions
|
!media nowplaying
|
||||||
!media recent [movies|tv] # recently added (default: both)
|
!media recent [movies|tv]
|
||||||
!media watched # what you recently finished
|
!media watched
|
||||||
|
!media find <query>
|
||||||
|
!media resume
|
||||||
|
!media random [movie|tv]
|
||||||
|
|
||||||
# Downloads (Sonarr/Radarr/NZBGet/qBittorrent)
|
# Sonarr / Radarr / Lidarr
|
||||||
!media queue # combined Sonarr + Radarr queue
|
!media queue
|
||||||
!media activity # NZBGet + qBt — current downloads
|
!media upcoming
|
||||||
!media upcoming # Sonarr calendar, next 7 days
|
!media missing
|
||||||
!media missing # Sonarr wanted/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 |
|
## Build & install
|
||||||
|---------|-----|
|
|
||||||
| 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/maubot-media
|
git clone https://git.3ddbrewery.com/maddox/maubot-media.git
|
||||||
zip -rq com.3ddbrewery.media-v0.1.0.mbp maubot.yaml base-config.yaml media_bot/ README.md -x '*/__pycache__/*'
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
# Media bot configuration
|
# 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
|
http_timeout: 15
|
||||||
default_results: 5
|
default_results: 5
|
||||||
|
|
@ -7,82 +11,103 @@ default_results: 5
|
||||||
posters_enabled: true
|
posters_enabled: true
|
||||||
|
|
||||||
# Matrix users allowed to approve/decline Seerr requests via 👍/👎 reactions.
|
# Matrix users allowed to approve/decline Seerr requests via 👍/👎 reactions.
|
||||||
admin_users:
|
admin_users: []
|
||||||
- "@maddox:fails.me"
|
|
||||||
|
|
||||||
# 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.
|
# Must be a room the bot has joined. Leave empty to disable.
|
||||||
notifications_room: ""
|
notifications_room: ""
|
||||||
|
|
||||||
# Shared secret Seerr must send as `Authorization: Bearer <secret>` header.
|
# Shared secret Seerr must send as `Authorization: Bearer <secret>` header
|
||||||
# Generate with `openssl rand -hex 32`. Empty = no auth check (NOT recommended).
|
# when calling the seerr-webhook endpoint. Generate with `openssl rand -hex 32`.
|
||||||
|
# Empty = no auth check (NOT recommended).
|
||||||
seerr_webhook_secret: ""
|
seerr_webhook_secret: ""
|
||||||
|
|
||||||
# Sonarr → Matrix subscription notifications.
|
# Sonarr → Matrix subscription notifications.
|
||||||
# Configure Sonarr → Settings → Connect → Webhook with URL
|
# 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.
|
# and Headers: `Authorization: Bearer <secret>`. Triggers on Download events.
|
||||||
sonarr_webhook_secret: ""
|
sonarr_webhook_secret: ""
|
||||||
|
|
||||||
# Daily digest — fires once a day in `notifications_room`.
|
# Daily digest — fires once a day in `notifications_room`.
|
||||||
digest_enabled: true
|
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.
|
# Optional Sunday recap pulled from an ntfy topic (e.g. an external cleanup job
|
||||||
# The cleaner runs Sunday 4:20 AM (cron on control); digest fires after.
|
# posts its summary there). Leave ntfy_url empty to skip.
|
||||||
ntfy_url: "https://ntfy.3ddbrewery.com"
|
ntfy_url: ""
|
||||||
emby_cleaner_topic: "emby-cleaner"
|
emby_cleaner_topic: "emby-cleaner"
|
||||||
|
|
||||||
# --- Service endpoints ---
|
# --- 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:
|
seerr:
|
||||||
url: http://192.168.1.80:5056
|
url: ""
|
||||||
api_key: CHANGEME
|
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:
|
sonarr:
|
||||||
url: http://192.168.1.80:8989
|
url: http://sonarr:8989
|
||||||
api_key: CHANGEME
|
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:
|
radarr:
|
||||||
url: http://192.168.1.80:7878
|
url: http://radarr:7878
|
||||||
api_key: CHANGEME
|
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).
|
# Lidarr — music. API v1 (different from Sonarr/Radarr v3).
|
||||||
# Profile/folder defaults: leave null to auto-pick the first one Lidarr returns.
|
|
||||||
lidarr:
|
lidarr:
|
||||||
url: http://192.168.1.80:8686
|
url: http://lidarr:8686
|
||||||
api_key: CHANGEME
|
api_key: CHANGEME
|
||||||
quality_profile_id: null # e.g. 3 for "Standard"
|
quality_profile_id: null
|
||||||
metadata_profile_id: null # e.g. 1 for "Standard"
|
metadata_profile_id: null
|
||||||
root_folder_path: null # e.g. "/media/Music"
|
root_folder_path: null # e.g. "/music"
|
||||||
monitor: "all" # all|future|missing|existing|first|latest|none
|
monitor: "all"
|
||||||
search_on_add: true # kick off a search for missing albums right after add
|
search_on_add: true
|
||||||
|
|
||||||
emby:
|
emby:
|
||||||
url: http://192.168.1.120:8096/emby
|
url: http://emby:8096/emby
|
||||||
api_key: CHANGEME
|
api_key: CHANGEME
|
||||||
|
|
||||||
nzbget:
|
nzbget:
|
||||||
url: http://192.168.1.122:6789
|
url: http://nzbget:6789
|
||||||
username: nzbget
|
username: nzbget
|
||||||
password: CHANGEME
|
password: CHANGEME
|
||||||
|
|
||||||
qbittorrent:
|
qbittorrent:
|
||||||
url: http://192.168.1.122:8082
|
url: http://qbittorrent:8080
|
||||||
username: admin
|
username: admin
|
||||||
password: CHANGEME
|
password: CHANGEME
|
||||||
|
|
||||||
# Map Matrix user IDs to per-service user identifiers.
|
# Map Matrix user IDs to per-service user identifiers.
|
||||||
# Senders not in this map get an "unauthorized" reply.
|
# 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:
|
# Optional per-user defaults:
|
||||||
# default_media_type: "movie" | "tv" — used by `!media request <q>` when no
|
# default_media_type: "movie" | "tv" — used by `!media request <q>` when no
|
||||||
# --tv/--movie flag is given
|
# --tv/--movie flag is given
|
||||||
# result_count: int — overrides default_results above
|
# result_count: int — overrides default_results above
|
||||||
user_map:
|
#
|
||||||
"@maddox:fails.me":
|
# Example (uncomment and edit):
|
||||||
seerr_user_id: 1
|
# user_map:
|
||||||
emby_user_id: "052e6796e9d94270858e05fb582ba5a6"
|
# "@alice:example.com":
|
||||||
"@jess:fails.me":
|
# seerr_user_id: 1
|
||||||
seerr_user_id: 2
|
# emby_user_id: "abc123def456"
|
||||||
emby_user_id: "TBD"
|
# default_media_type: "movie"
|
||||||
|
user_map: {}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
maubot: 0.3.1
|
maubot: 0.3.1
|
||||||
id: com.3ddbrewery.media
|
id: com.3ddbrewery.media
|
||||||
version: 0.5.4
|
version: 0.6.0
|
||||||
license: MIT
|
license: MIT
|
||||||
modules:
|
modules:
|
||||||
- media_bot
|
- 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.nzbget = NzbgetClient(self.session, n["url"], n["username"], n["password"])
|
||||||
self.qbt = QbtClient(self.session, q["url"], q["username"], q["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._search_cache = {}
|
||||||
self._pending_requests = {}
|
self._pending_requests = {}
|
||||||
await self._ensure_db_schema()
|
await self._ensure_db_schema()
|
||||||
|
|
@ -189,6 +193,16 @@ class MediaBot(Plugin):
|
||||||
|
|
||||||
# ---------- helpers ----------
|
# ---------- 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]:
|
def _resolve_user(self, sender: str) -> Optional[dict]:
|
||||||
return (self.config["user_map"] or {}).get(sender)
|
return (self.config["user_map"] or {}).get(sender)
|
||||||
|
|
||||||
|
|
@ -256,12 +270,16 @@ class MediaBot(Plugin):
|
||||||
await evt.mark_read()
|
await evt.mark_read()
|
||||||
msg = (
|
msg = (
|
||||||
"**Media bot commands**\n\n"
|
"**Media bot commands**\n\n"
|
||||||
"*Search & request (Seerr)*\n"
|
"*Search & request*\n"
|
||||||
"- `!media search <query>` — top results (numbered)\n"
|
"- `!media search <query>` — top results (numbered)\n"
|
||||||
"- `!media request <query> [--tv|--movie]` — request top hit\n"
|
"- `!media request <query> [--tv|--movie]` — request top hit\n"
|
||||||
"- `!media request <N>` — pick item N from your last search/trending\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"
|
"- `!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"
|
"*Library (Emby)*\n"
|
||||||
"- `!media nowplaying` — active sessions\n"
|
"- `!media nowplaying` — active sessions\n"
|
||||||
"- `!media recent [movies|tv]` — recently added\n"
|
"- `!media recent [movies|tv]` — recently added\n"
|
||||||
|
|
@ -299,14 +317,19 @@ class MediaBot(Plugin):
|
||||||
await evt.mark_read()
|
await evt.mark_read()
|
||||||
if await self._reject_unmapped(evt):
|
if await self._reject_unmapped(evt):
|
||||||
return
|
return
|
||||||
|
if self.seerr_enabled:
|
||||||
try:
|
try:
|
||||||
results = await self.seerr.search(query)
|
results = await self.seerr.search(query)
|
||||||
except SeerrError as ex:
|
except SeerrError as ex:
|
||||||
await self._say(evt, f"Search failed: {ex}")
|
await self._say(evt, f"Search failed: {ex}")
|
||||||
return
|
return
|
||||||
results = [r for r in results if r.get("mediaType") in ("movie", "tv")]
|
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:
|
if not results:
|
||||||
await self._say(evt, f"No Seerr results for **{query}**.")
|
await self._say(evt, f"No results for **{query}**.")
|
||||||
return
|
return
|
||||||
n = self._result_count(evt.sender)
|
n = self._result_count(evt.sender)
|
||||||
shown = results[:n]
|
shown = results[:n]
|
||||||
|
|
@ -327,8 +350,8 @@ class MediaBot(Plugin):
|
||||||
return
|
return
|
||||||
user_cfg = self._resolve_user(evt.sender) or {}
|
user_cfg = self._resolve_user(evt.sender) or {}
|
||||||
seerr_uid = user_cfg.get("seerr_user_id")
|
seerr_uid = user_cfg.get("seerr_user_id")
|
||||||
if not seerr_uid:
|
if self.seerr_enabled and 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
|
return
|
||||||
|
|
||||||
# Numbered selection: !media request 2 → 2nd item from last search/trending
|
# 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):
|
if idx < 0 or idx >= len(cached):
|
||||||
await self._say(evt, f"Pick a number 1-{len(cached)}.")
|
await self._say(evt, f"Pick a number 1-{len(cached)}.")
|
||||||
return
|
return
|
||||||
top = cached[idx]
|
await self._do_request(evt, seerr_uid, cached[idx])
|
||||||
await self._do_request(evt, seerr_uid, top)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Free-text path: optional --tv/--movie flag, else fall back to user default
|
# Free-text path: optional --tv/--movie flag, else fall back to user default
|
||||||
|
|
@ -362,12 +384,17 @@ class MediaBot(Plugin):
|
||||||
if not q:
|
if not q:
|
||||||
await self._say(evt, "Need a search query, e.g. `!media request dune --movie`.")
|
await self._say(evt, "Need a search query, e.g. `!media request dune --movie`.")
|
||||||
return
|
return
|
||||||
|
if self.seerr_enabled:
|
||||||
try:
|
try:
|
||||||
results = await self.seerr.search(q)
|
results = await self.seerr.search(q)
|
||||||
except SeerrError as ex:
|
except SeerrError as ex:
|
||||||
await self._say(evt, f"Search failed: {ex}")
|
await self._say(evt, f"Search failed: {ex}")
|
||||||
return
|
return
|
||||||
candidates = [r for r in results if r.get("mediaType") in ("movie", "tv")]
|
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:
|
if forced_type:
|
||||||
candidates = [r for r in candidates if r.get("mediaType") == forced_type]
|
candidates = [r for r in candidates if r.get("mediaType") == forced_type]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
|
|
@ -375,7 +402,14 @@ class MediaBot(Plugin):
|
||||||
return
|
return
|
||||||
await self._do_request(evt, seerr_uid, candidates[0])
|
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")
|
media_type = item.get("mediaType")
|
||||||
tmdb_id = item.get("id")
|
tmdb_id = item.get("id")
|
||||||
title = item.get("title") or item.get("name") or "?"
|
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:
|
if request_id and status_id == 1 and sent_id:
|
||||||
self._track_request(sent_id, request_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")
|
@media.subcommand("requests", help="Show your pending/processing Seerr requests")
|
||||||
async def cmd_requests(self, evt: MessageEvent) -> None:
|
async def cmd_requests(self, evt: MessageEvent) -> None:
|
||||||
await evt.mark_read()
|
await evt.mark_read()
|
||||||
if await self._reject_unmapped(evt):
|
if await self._reject_unmapped(evt):
|
||||||
return
|
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)
|
user_cfg = self._resolve_user(evt.sender)
|
||||||
seerr_uid = (user_cfg or {}).get("seerr_user_id")
|
seerr_uid = (user_cfg or {}).get("seerr_user_id")
|
||||||
if not seerr_uid:
|
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
|
return
|
||||||
try:
|
try:
|
||||||
reqs = await self.seerr.user_requests(seerr_uid, take=10)
|
reqs = await self.seerr.user_requests(seerr_uid, take=10)
|
||||||
|
|
@ -435,6 +611,9 @@ class MediaBot(Plugin):
|
||||||
await evt.mark_read()
|
await evt.mark_read()
|
||||||
if await self._reject_unmapped(evt):
|
if await self._reject_unmapped(evt):
|
||||||
return
|
return
|
||||||
|
if not self.seerr_enabled:
|
||||||
|
await self._say(evt, "Seerr isn't configured — `!media trending` is unavailable.")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
results = await self.seerr.trending()
|
results = await self.seerr.trending()
|
||||||
except SeerrError as ex:
|
except SeerrError as ex:
|
||||||
|
|
@ -445,6 +624,8 @@ class MediaBot(Plugin):
|
||||||
if not results:
|
if not results:
|
||||||
await self._say(evt, "Nothing trending right now.")
|
await self._say(evt, "Nothing trending right now.")
|
||||||
return
|
return
|
||||||
|
for r in results:
|
||||||
|
r["_source"] = "seerr"
|
||||||
self._stash_search(evt, results)
|
self._stash_search(evt, results)
|
||||||
lines = [f"**Trending ({len(results)}):**"]
|
lines = [f"**Trending ({len(results)}):**"]
|
||||||
for i, r in enumerate(results, 1):
|
for i, r in enumerate(results, 1):
|
||||||
|
|
@ -543,7 +724,7 @@ class MediaBot(Plugin):
|
||||||
user_cfg = self._resolve_user(evt.sender) or {}
|
user_cfg = self._resolve_user(evt.sender) or {}
|
||||||
emby_uid = user_cfg.get("emby_user_id")
|
emby_uid = user_cfg.get("emby_user_id")
|
||||||
if not emby_uid or emby_uid == "TBD":
|
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
|
return
|
||||||
try:
|
try:
|
||||||
items = await self.emby.user_played(emby_uid, limit=10)
|
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 {}
|
user_cfg = self._resolve_user(evt.sender) or {}
|
||||||
emby_uid = user_cfg.get("emby_user_id")
|
emby_uid = user_cfg.get("emby_user_id")
|
||||||
if not emby_uid or emby_uid == "TBD":
|
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
|
return
|
||||||
try:
|
try:
|
||||||
items = await self.emby.resume(emby_uid, limit=10)
|
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)
|
data = await self._get("/api/v3/wanted/missing", params=params)
|
||||||
return (data or {}).get("records", []) if isinstance(data, dict) else (data or [])
|
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):
|
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):
|
class LidarrClient(ArrClient):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue