Files
plezy/lib/media/media_browser_dialect.dart
edde746 05fd622968 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.
2026-08-05 06:09:26 +02:00

190 lines
9.4 KiB
Dart

import 'media_backend.dart';
/// Which flavour of the MediaBrowser HTTP API a server speaks.
///
/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire
/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query
/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header
/// and `api_key=` query fallback. Plezy therefore drives both through one
/// client stack ([JellyfinClient]) and keeps every delta in this one type.
///
/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5:
/// - Jellyfin 10.9 renamed a batch of user-scoped write routes to unprefixed
/// forms and added `/Users/Me`. Emby only has the original user-scoped
/// spellings — see [MediaBrowserPaths].
/// - Trickplay, `/MediaSegments`, `/Audio/{id}/Lyrics`, `/Items/Filters` and
/// Quick Connect do not exist on Emby. `/Audio/{id}/Lyrics` is actively
/// harmful there: Emby parses `Lyrics` as a container name and starts an
/// ffmpeg transcode.
/// - Emby tolerates unknown `Fields`/`SortBy` values, so the shared field sets
/// need no per-dialect pruning.
enum MediaBrowserDialect {
jellyfin,
emby;
/// Stable wire/persistence id. Matches the [MediaBackend] and
/// `ConnectionKind` ids for the same server kind.
String get id => switch (this) {
MediaBrowserDialect.jellyfin => 'jellyfin',
MediaBrowserDialect.emby => 'emby',
};
static MediaBrowserDialect fromId(String id) => switch (id) {
'jellyfin' => MediaBrowserDialect.jellyfin,
'emby' => MediaBrowserDialect.emby,
_ => throw ArgumentError('Unknown MediaBrowserDialect id: $id'),
};
/// Like [fromId] but tolerates legacy/missing values by defaulting to
/// Jellyfin. Persisted connection rows written before Emby support carry no
/// `dialect` key at all, and those are Jellyfin by construction.
static MediaBrowserDialect fromIdOrJellyfin(Object? id) => switch (id) {
'emby' => MediaBrowserDialect.emby,
_ => MediaBrowserDialect.jellyfin,
};
MediaBackend get backend => switch (this) {
MediaBrowserDialect.jellyfin => MediaBackend.jellyfin,
MediaBrowserDialect.emby => MediaBackend.emby,
};
/// Product name, used verbatim in UI that names the backend. These are
/// trademarks, so they are not localized.
String get productName => switch (this) {
MediaBrowserDialect.jellyfin => 'Jellyfin',
MediaBrowserDialect.emby => 'Emby',
};
/// Placeholder host shown in the "server URL" field.
String get exampleBaseUrl => switch (this) {
MediaBrowserDialect.jellyfin => 'https://jellyfin.example.com',
MediaBrowserDialect.emby => 'https://emby.example.com',
};
/// UDP payload the server answers on port 7359. Emby ignores Jellyfin's
/// string and vice versa, which makes the datagram itself a reliable
/// dialect discriminator during LAN discovery.
String get lanDiscoveryMessage => switch (this) {
MediaBrowserDialect.jellyfin => 'who is JellyfinServer?',
MediaBrowserDialect.emby => 'who is EmbyServer?',
};
/// Ports appended when the user types a bare host, most-likely first.
/// Both ship 8096 for HTTP; Emby's default HTTPS port is 8920.
List<int> get httpsPortGuesses => switch (this) {
MediaBrowserDialect.jellyfin => const [8096],
MediaBrowserDialect.emby => const [8920, 8096],
};
/// `/QuickConnect/*` plus `POST /Users/AuthenticateWithQuickConnect`.
bool get supportsQuickConnect => this == MediaBrowserDialect.jellyfin;
/// `/Videos/{id}/Trickplay/{width}/{n}.jpg` sprite sheets and the
/// `Trickplay` item field (Jellyfin 10.9+). Emby 404s on the route and never
/// fills the field; its own preview transports are unwired — see
/// [ServerCapabilities.emby].
bool get supportsTrickplay => this == MediaBrowserDialect.jellyfin;
/// `/MediaSegments/{itemId}` intro/outro/credit markers (Jellyfin 10.10+).
/// Emby 404s; chapter-name fallback still applies.
bool get supportsMediaSegments => this == MediaBrowserDialect.jellyfin;
/// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the
/// route resolves to audio streaming with `Lyrics` as the container and
/// spawns an ffmpeg process that fails with a 500.
bool get supportsLyrics => this == MediaBrowserDialect.jellyfin;
/// `GET /Items/Filters`, the single call that returns a library's distinct
/// genres, official ratings, tags and years. Emby has no aggregate route; the
/// client reassembles the same payload from `/Genres`, `/OfficialRatings`,
/// `/Tags` and `/Years`.
bool get supportsAggregateItemFilters => this == MediaBrowserDialect.jellyfin;
/// `POST /Users/{uid}/Items/{id}/HideFromResume` hides an item from Continue
/// Watching without clearing its resume position.
///
/// Emby-only, and the one capability where Emby is ahead of Jellyfin:
/// measured 200 on Emby 4.9.5 (the row leaves `/Users/{uid}/Items/Resume`
/// while `UserData.PlaybackPositionTicks` survives), and 404 on Jellyfin
/// 10.11 for both that spelling and `/UserItems/{id}/HideFromResume`.
bool get supportsContinueWatchingRemoval => this == MediaBrowserDialect.emby;
/// `POST /Items/{id}` persists genre and tag edits from the `GenreItems` /
/// `TagItems` name-pair arrays rather than the plain `Genres` / `Tags` string
/// lists.
///
/// Measured on Emby 4.9.5: sending `Genres: ['Action']` alone round-trips as
/// an empty list and the `/Genres` facet stays empty, while
/// `GenreItems: [{'Name': 'Action'}]` sticks and is immediately indexed. The
/// sibling fields (`Studios`, `People`, `ProductionLocations`, `Taglines`,
/// `Overview`, `OriginalTitle`) all persist from their ordinary shapes on
/// both dialects.
bool get metadataWritesUseNamePairLists => this == MediaBrowserDialect.emby;
/// `GET /Shows/NextUp` answers an unscoped, library-wide query.
///
/// Jellyfin-only. Measured on Emby 4.9.5 with one played episode: the
/// unscoped query returns `TotalRecordCount: 0` under every parameter
/// combination tried (`ParentId` on the view or the series, `SeriesId=`,
/// `Recursive`, `GroupItems`, `EnableResumable`, `SortBy`), while the same
/// query with `SeriesId=<series>` returns the series' 23 remaining episodes.
/// Emby therefore only computes Next Up per series, and the library-wide
/// shelf has to be reconstructed client-side from recently played episodes.
bool get supportsGlobalNextUp => this == MediaBrowserDialect.jellyfin;
/// The resume route returns only items with a saved playback position.
///
/// Jellyfin-only. Measured with one in-progress movie and 30 started series:
/// Jellyfin's `/UserItems/Resume` returned exactly the movie, while Emby's
/// `/Users/{uid}/Items/Resume` returned 30 rows — the movie plus 29
/// zero-position *next* episodes — and `Filters=IsResumable` did not remove
/// them. Emby's own UI merges both into one shelf; Plezy models Continue
/// Watching and Next Up as separate rows, so the Emby resume leg is filtered
/// to genuine progress and the next-up rows come from the Next Up path
/// instead. Without the filter a started series occupies both rows and can
/// push the real in-progress item out of a limited one.
bool get resumeReturnsOnlyStartedItems => this == MediaBrowserDialect.jellyfin;
/// `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
/// `PlaySessionId`.
///
/// Measured on Emby 4.9.5: both return HTTP 400 `Value cannot be null.
/// (Parameter 'key')` when the field is absent, while
/// `/Sessions/Playing/Stopped` tolerates it. Jellyfin accepts all three
/// without one. Callers that never negotiated a PlaybackInfo session — the
/// offline watch-progress sync is the live example — therefore need a
/// synthesized id on Emby or their progress is silently dropped.
bool get requiresPlaySessionId => this == MediaBrowserDialect.emby;
/// `/Items?IncludeItemTypes=Playlist` honours a `MediaTypes` filter.
///
/// Measured on Emby 4.9.5: passing *any* `MediaTypes` value makes the server
/// discard `IncludeItemTypes` and return the whole index — 14554 rows of
/// `Genre`/`Person`/`Studio`/`Movie` instead of the one playlist. Emby also
/// never populates `MediaType` on a playlist DTO, not even for a playlist
/// created with `MediaType=Audio`, so there is nothing to filter on either
/// side and every playlist is returned regardless of the requested type.
bool get playlistsFilterByMediaType => this == MediaBrowserDialect.jellyfin;
/// True when the dialect only accepts the pre-10.9 `/Users/{userId}/…`
/// spelling of the user-scoped item routes.
bool get requiresUserScopedItemRoutes => this == MediaBrowserDialect.emby;
/// Best-effort dialect detection from a `/System/Info/Public` body.
///
/// Jellyfin reports `ProductName: "Jellyfin Server"`. Emby 4.9 omits
/// `ProductName` entirely but is the only one of the two that returns the
/// `RemoteAddresses` array. Returns `null` when neither signal is present so
/// callers keep whichever dialect the user picked.
static MediaBrowserDialect? detectFromPublicSystemInfo(Map<String, Object?> json) {
final productName = json['ProductName'];
if (productName is String && productName.isNotEmpty) {
final normalized = productName.toLowerCase();
if (normalized.contains('jellyfin')) return MediaBrowserDialect.jellyfin;
if (normalized.contains('emby')) return MediaBrowserDialect.emby;
}
if (json.containsKey('RemoteAddresses')) return MediaBrowserDialect.emby;
return null;
}
}