From f8f89b794e14adc1f8b96cc0ad50ffb65bb47876 Mon Sep 17 00:00:00 2001 From: Maddox Date: Sun, 3 May 2026 15:26:25 -0400 Subject: [PATCH] v0.6.1: Jellyfin support via emby.type config flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #3. Audit confirmed every Emby endpoint we use (/System/Info/Public, /Sessions, /Users/{id}/Items[/Latest|/Resume], /Items/{id}/Images/Primary) is API-compatible with Jellyfin, and the ?api_key= auth scheme works on both. So 'add Jellyfin support' is really 'document it and fix display labels' — no client branching needed. Added a 'type: emby|jellyfin' field inside the emby config block (defaults to emby for backward compat). New _media_label() helper sources the label from config and is threaded through: - !media health server name - !media nowplaying header + empty-state - !media help section header + health command summary - daily digest 'not yet in ' line README updated with a Jellyfin example block and notes that the URL should NOT include the /emby suffix when pointing at Jellyfin. --- README.md | 15 ++++++++++++--- base-config.yaml | 4 ++++ maubot.yaml | 2 +- media_bot/bot.py | 34 ++++++++++++++++++++-------------- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0d140c1..c6f5e18 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 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`). +- **Library + playback** via Emby **or Jellyfin** (`recent`, `nowplaying`, `watched`, `find`, `resume`, `random`). - **Sonarr / Radarr / Lidarr** queue, calendar, missing, music search/add. - **Downloads** — NZBGet + qBittorrent activity, completed, speed, pause/resume. - **Subscriptions** — `!media subscribe `, with a Sonarr webhook pinging the room when new episodes import. @@ -109,9 +109,18 @@ seerr: api_key: ``` -### Lidarr / Emby / Downloads +### Lidarr / Emby (or Jellyfin) / 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). +All optional — the corresponding commands fail gracefully when the service isn't reachable. Commands that need them: Lidarr (`!media music`), Emby/Jellyfin (`!media nowplaying|recent|watched|find|resume|random`), NZBGet/qBt (`!media activity|completed|speed|pause|unpause` and the daily digest's "completed" section). + +The bot uses the same config block (`emby:`) for both servers — they expose the same API surface. Set `emby.type: jellyfin` so the display labels (`!media health`, `!media nowplaying`, etc.) read correctly: + +```yaml +emby: + type: jellyfin # or "emby" (default) + url: http://:8096 # no /emby suffix on Jellyfin + api_key: +``` ### Webhooks (optional) diff --git a/base-config.yaml b/base-config.yaml index 421190b..4b74f0d 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -79,6 +79,10 @@ lidarr: search_on_add: true emby: + # Set to "jellyfin" for a Jellyfin server. Affects display labels only — + # the API surface used (/Users/{id}/Items, /Sessions, /System/Info/Public, + # etc.) is identical between the two. + type: emby url: http://emby:8096/emby api_key: CHANGEME diff --git a/maubot.yaml b/maubot.yaml index f22d8ee..3544e8d 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.3.1 id: com.3ddbrewery.media -version: 0.6.0 +version: 0.6.1 license: MIT modules: - media_bot diff --git a/media_bot/bot.py b/media_bot/bot.py index 8894202..9502fbc 100644 --- a/media_bot/bot.py +++ b/media_bot/bot.py @@ -206,6 +206,11 @@ class MediaBot(Plugin): def _resolve_user(self, sender: str) -> Optional[dict]: return (self.config["user_map"] or {}).get(sender) + def _media_label(self) -> str: + """Display label for the configured Emby/Jellyfin server.""" + kind = ((self.config["emby"] or {}).get("type") or "emby").lower() + return "Jellyfin" if kind == "jellyfin" else "Emby" + async def _say(self, evt: MessageEvent, message: str) -> None: await evt.respond(f"{evt.sender}\n\n{message}") @@ -280,7 +285,7 @@ class MediaBot(Plugin): "_(Seerr not configured — search/request go directly to Sonarr+Radarr; " "no approval workflow.)_\n\n") + - "*Library (Emby)*\n" + f"*Library ({self._media_label()})*\n" "- `!media nowplaying` — active sessions\n" "- `!media recent [movies|tv]` — recently added\n" "- `!media watched` — what you recently finished\n" @@ -291,7 +296,7 @@ class MediaBot(Plugin): "- `!media queue` — combined queue\n" "- `!media upcoming` — Sonarr calendar (next 7 days)\n" "- `!media missing` — Sonarr wanted/missing\n" - "- `!media health` — Sonarr/Radarr/Lidarr/Seerr/Emby/NZBGet/qBt status\n\n" + f"- `!media health` — Sonarr/Radarr/Lidarr/Seerr/{self._media_label()}/NZBGet/qBt status\n\n" "*Lidarr (music)*\n" "- `!media music ` — search MusicBrainz (numbered)\n" "- `!media music add ` — add the artist to Lidarr\n\n" @@ -660,13 +665,13 @@ class MediaBot(Plugin): try: sessions = await self.emby.sessions() except EmbyError as ex: - await self._say(evt, f"Emby fetch failed: {ex}") + await self._say(evt, f"{self._media_label()} fetch failed: {ex}") return active = [s for s in sessions if s.get("NowPlayingItem")] if not active: - await self._say(evt, "Nothing playing on Emby right now.") + await self._say(evt, f"Nothing playing on {self._media_label()} right now.") return - lines = [f"**Now playing on Emby ({len(active)}):**"] + lines = [f"**Now playing on {self._media_label()} ({len(active)}):**"] for s in active: item = s.get("NowPlayingItem") or {} user = s.get("UserName") or "?" @@ -705,7 +710,7 @@ class MediaBot(Plugin): try: items = await self.emby.recently_added(emby_uid, limit=10, item_types=item_types) except EmbyError as ex: - await self._say(evt, f"Emby fetch failed: {ex}") + await self._say(evt, f"{self._media_label()} fetch failed: {ex}") return if not items: await self._say(evt, "Nothing recently added.") @@ -729,7 +734,7 @@ class MediaBot(Plugin): try: items = await self.emby.user_played(emby_uid, limit=10) except EmbyError as ex: - await self._say(evt, f"Emby fetch failed: {ex}") + await self._say(evt, f"{self._media_label()} fetch failed: {ex}") return if not items: await self._say(evt, "No recently watched items.") @@ -752,7 +757,7 @@ class MediaBot(Plugin): try: items = await self.emby.find(emby_uid, query, limit=10) except EmbyError as ex: - await self._say(evt, f"Emby fetch failed: {ex}") + await self._say(evt, f"{self._media_label()} fetch failed: {ex}") return if not items: await self._say(evt, f"Nothing in the library matching **{query}**.") @@ -775,7 +780,7 @@ class MediaBot(Plugin): try: items = await self.emby.resume(emby_uid, limit=10) except EmbyError as ex: - await self._say(evt, f"Emby fetch failed: {ex}") + await self._say(evt, f"{self._media_label()} fetch failed: {ex}") return if not items: await self._say(evt, "Nothing to resume — you've finished everything you started.") @@ -809,7 +814,7 @@ class MediaBot(Plugin): try: item = await self.emby.random_unplayed(emby_uid, item_type=item_type) except EmbyError as ex: - await self._say(evt, f"Emby fetch failed: {ex}") + await self._say(evt, f"{self._media_label()} fetch failed: {ex}") return if not item: await self._say(evt, f"No unwatched {item_type.lower()}s found — go you.") @@ -985,12 +990,13 @@ class MediaBot(Plugin): tag = f" (update available, {update} behind)" if update else "" blocks.append([f"**Seerr:** ✅ v{ver}{tag}"]) + media_label = self._media_label() if isinstance(emby_s, Exception): - blocks.append([f"**Emby:** ❌ unreachable ({emby_s})"]) + blocks.append([f"**{media_label}:** ❌ unreachable ({emby_s})"]) else: ver = emby_s.get("Version") or "?" - name = emby_s.get("ServerName") or "Emby" - blocks.append([f"**Emby:** ✅ {name} v{ver}"]) + name = emby_s.get("ServerName") or media_label + blocks.append([f"**{media_label}:** ✅ {name} v{ver}"]) if isinstance(nzb_s, Exception): blocks.append([f"**NZBGet:** ❌ unreachable ({nzb_s})"]) @@ -1708,7 +1714,7 @@ class MediaBot(Plugin): if nzb_unique or qbt_unique: lines.append("") lines.append( - f"**✅ Also completed (not yet in Emby): " + f"**✅ Also completed (not yet in {self._media_label()}): " f"{len(nzb_unique) + len(qbt_unique)}** " f"(NZBGet: {len(nzb_unique)} · qBt: {len(qbt_unique)})" )