feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_browser_dialect.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
@@ -9,22 +10,38 @@ import '../utils/url_utils.dart';
|
||||
/// (e.g. database column values).
|
||||
enum ConnectionKind {
|
||||
plex,
|
||||
jellyfin;
|
||||
jellyfin,
|
||||
emby;
|
||||
|
||||
String get id => switch (this) {
|
||||
ConnectionKind.plex => 'plex',
|
||||
ConnectionKind.jellyfin => 'jellyfin',
|
||||
ConnectionKind.emby => 'emby',
|
||||
};
|
||||
|
||||
static ConnectionKind fromId(String id) => switch (id) {
|
||||
'plex' => ConnectionKind.plex,
|
||||
'jellyfin' => ConnectionKind.jellyfin,
|
||||
'emby' => ConnectionKind.emby,
|
||||
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
ConnectionKind.plex => MediaBackend.plex,
|
||||
ConnectionKind.jellyfin => MediaBackend.jellyfin,
|
||||
ConnectionKind.emby => MediaBackend.emby,
|
||||
};
|
||||
|
||||
/// The MediaBrowser dialect this kind speaks, or `null` for Plex.
|
||||
MediaBrowserDialect? get dialect => switch (this) {
|
||||
ConnectionKind.plex => null,
|
||||
ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin,
|
||||
ConnectionKind.emby => MediaBrowserDialect.emby,
|
||||
};
|
||||
|
||||
static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) {
|
||||
MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin,
|
||||
MediaBrowserDialect.emby => ConnectionKind.emby,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +205,9 @@ class PlexAccountConnection extends Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-server Jellyfin connection.
|
||||
/// A single-server connection to a MediaBrowser-family server — Jellyfin or its
|
||||
/// Emby ancestor. [dialect] selects which of the two wire dialects this
|
||||
/// connection speaks; every other field has the same meaning on both.
|
||||
class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
@@ -202,10 +221,14 @@ class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
/// Which MediaBrowser dialect this server speaks. Drives [kind], [backend]
|
||||
/// and every route/capability delta in [JellyfinClient].
|
||||
final MediaBrowserDialect dialect;
|
||||
|
||||
/// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
|
||||
final String baseUrl;
|
||||
|
||||
/// Candidate server URLs for this Jellyfin server, with [baseUrl] first.
|
||||
/// Candidate server URLs for this server, with [baseUrl] first.
|
||||
/// Existing installs only have [baseUrl]; deserialization backfills this.
|
||||
final List<String> baseUrls;
|
||||
|
||||
@@ -215,7 +238,7 @@ class JellyfinConnection extends Connection {
|
||||
/// Server's machine identifier (System/Info `Id`).
|
||||
final String serverMachineId;
|
||||
|
||||
/// Authenticated Jellyfin user id (UUID).
|
||||
/// Authenticated user id. A UUID on Jellyfin, an opaque hex string on Emby.
|
||||
final String userId;
|
||||
|
||||
/// Authenticated user's display name.
|
||||
@@ -228,13 +251,13 @@ class JellyfinConnection extends Connection {
|
||||
/// `Authorization: MediaBrowser DeviceId="..."` header).
|
||||
final String deviceId;
|
||||
|
||||
/// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`).
|
||||
/// Whether this user is a server admin (`/Users/{id}.Policy.IsAdministrator`).
|
||||
/// Captured at auth time so the UI can gate admin-only entries (delete,
|
||||
/// match/unmatch, edit metadata) without an extra round-trip.
|
||||
final bool isAdministrator;
|
||||
|
||||
/// The authenticated user's `PrimaryImageTag`, or `null` when they have no
|
||||
/// profile picture. Jellyfin omits the key entirely in that case, and the
|
||||
/// profile picture. The server omits the key entirely in that case, and the
|
||||
/// tag is `MD5(imagePath + lastModified)` so it changes on every upload —
|
||||
/// which makes the derived avatar URL self-invalidating. Captured at auth
|
||||
/// time and refreshed by [JellyfinClient.checkHealth].
|
||||
@@ -250,6 +273,7 @@ class JellyfinConnection extends Connection {
|
||||
required this.userName,
|
||||
required this.accessToken,
|
||||
required this.deviceId,
|
||||
this.dialect = MediaBrowserDialect.jellyfin,
|
||||
this.isAdministrator = false,
|
||||
this.primaryImageTag,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
@@ -259,7 +283,7 @@ class JellyfinConnection extends Connection {
|
||||
baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.jellyfin;
|
||||
ConnectionKind get kind => ConnectionKind.fromDialect(dialect);
|
||||
|
||||
@override
|
||||
String get displayName => '$userName · $serverName';
|
||||
@@ -306,11 +330,12 @@ class JellyfinConnection extends Connection {
|
||||
String? userName,
|
||||
String? accessToken,
|
||||
String? deviceId,
|
||||
MediaBrowserDialect? dialect,
|
||||
bool? isAdministrator,
|
||||
String? primaryImageTag,
|
||||
|
||||
/// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the
|
||||
/// user DTO, so a refresh must be able to null the cached value — a bare
|
||||
/// Deleting a profile picture drops `PrimaryImageTag` from the user DTO, so
|
||||
/// a refresh must be able to null the cached value — a bare
|
||||
/// `primaryImageTag: null` is indistinguishable from "unchanged".
|
||||
bool clearPrimaryImageTag = false,
|
||||
ConnectionStatus? status,
|
||||
@@ -328,6 +353,7 @@ class JellyfinConnection extends Connection {
|
||||
userName: userName ?? this.userName,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
dialect: dialect ?? this.dialect,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag),
|
||||
status: status ?? this.status,
|
||||
@@ -336,6 +362,9 @@ class JellyfinConnection extends Connection {
|
||||
);
|
||||
}
|
||||
|
||||
/// The persisted payload deliberately omits [dialect]: the `connections.kind`
|
||||
/// column is the authoritative, indexed discriminator and
|
||||
/// [JellyfinConnection.fromConfigJson] receives it from there.
|
||||
@override
|
||||
Map<String, Object?> toConfigJson() {
|
||||
return {
|
||||
@@ -358,6 +387,7 @@ class JellyfinConnection extends Connection {
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin,
|
||||
}) {
|
||||
final rawBaseUrls = json['baseUrls'];
|
||||
final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType<String>().toList(growable: false) : const <String>[];
|
||||
@@ -369,7 +399,8 @@ class JellyfinConnection extends Connection {
|
||||
id: id,
|
||||
baseUrl: baseUrl,
|
||||
baseUrls: baseUrls,
|
||||
serverName: json['serverName'] as String? ?? 'Jellyfin',
|
||||
dialect: dialect,
|
||||
serverName: json['serverName'] as String? ?? dialect.productName,
|
||||
serverMachineId: json['serverMachineId'] as String? ?? '',
|
||||
userId: json['userId'] as String? ?? '',
|
||||
userName: json['userName'] as String? ?? '',
|
||||
|
||||
@@ -161,12 +161,13 @@ class ConnectionRegistry {
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson(
|
||||
ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
dialect: kind.dialect!,
|
||||
),
|
||||
};
|
||||
if (revealed.migrated) {
|
||||
|
||||
Reference in New Issue
Block a user