v0.6.1: Jellyfin support via emby.type config flag

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 <server>' line

README updated with a Jellyfin example block and notes that the URL
should NOT include the /emby suffix when pointing at Jellyfin.
This commit is contained in:
Maddox 2026-05-03 15:26:25 -04:00
parent d90d58755f
commit f8f89b794e
4 changed files with 37 additions and 18 deletions

View file

@ -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 <show>`, with a Sonarr webhook pinging the room when new episodes import.
@ -109,9 +109,18 @@ seerr:
api_key: <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://<host>:8096 # no /emby suffix on Jellyfin
api_key: <api-key>
```
### Webhooks (optional)

View file

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

View file

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

View file

@ -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 <q>` — search MusicBrainz (numbered)\n"
"- `!media music add <q|N>` — 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)})"
)