Files
plezy/lib/media/server_capabilities.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

176 lines
7.0 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Static capability flags advertised by a [MediaServerClient]. UI consults
/// these to gate feature affordances per server (e.g. hide Live TV when no
/// connected server supports it).
///
/// These describe what the *backend kind* supports in this app's current
/// implementation — not necessarily what the wire protocol can do. As more
/// Jellyfin features are wired in over time, the corresponding flags flip
/// without changing call sites.
class ServerCapabilities {
/// This backend kind has a Live TV / DVR API the app can talk to. Whether
/// a *specific* server has Live TV configured is a runtime concern —
/// [MultiServerProvider.checkLiveTvAvailability] probes each server and
/// only those with channels surface in [MultiServerProvider.liveTvServers].
final bool liveTv;
/// Server has DVR/recording lineups (Plex `/livetv/dvrs`). Channel listing
/// is gated by [liveTv]; this flag enables the additional recordings/scheduling
/// UI. Jellyfin's DVR API isn't wired in this app yet, so it stays false even
/// when [liveTv] is true.
final bool liveTvDvr;
/// Server can transcode video.
final bool videoTranscoding;
/// Server provides curated recommendation hubs (Plex Discover). Jellyfin
/// returns synthesized hubs but with sparser categorisation.
final bool richHubs;
/// Numeric ratings (Plex 010 via [Item.userRating]). Jellyfin has no
/// numeric user rating, so star sliders should be hidden.
final bool numericUserRating;
/// Per-user favorite flag ("heart") on media items. Jellyfin exposes it via
/// `/UserFavoriteItems/{itemId}?userId=...`; Plex has no equivalent.
final bool userFavorites;
/// Hide an item from Continue Watching without changing watch state or
/// playback progress. Plex exposes this directly; Jellyfin does not.
final bool continueWatchingRemoval;
/// External subtitle search/marketplace (Plex `/library/metadata/{id}/subtitles`).
/// Hides the "Search subtitles" affordance when false.
final bool externalSubtitleSearch;
/// Server exposes metadata edit endpoints. Hides edit affordances when false.
final bool richMetadataEdit;
/// Server can supply thumbnails for the player's seek-bar scrub preview.
/// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite
/// sheets. Both backends are wired through [ScrubPreviewSource]; the flag
/// gates whether the player attempts the load at all.
final bool scrubThumbnails;
/// Library section exposes a folder hierarchy. Plex uses
/// `/library/sections/{id}/folders`; Jellyfin uses direct-child
/// `/Items?ParentId=...&Recursive=false` queries.
final bool folderGrouping;
/// Server can build an "instant mix" / radio track list from a seed item.
/// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues
/// (`POST /playQueues?type=audio&uri=...station...`).
final bool instantMix;
const ServerCapabilities({
this.liveTv = false,
this.liveTvDvr = false,
this.videoTranscoding = true,
this.richHubs = false,
this.numericUserRating = false,
this.userFavorites = false,
this.continueWatchingRemoval = false,
this.externalSubtitleSearch = false,
this.richMetadataEdit = false,
this.scrubThumbnails = false,
this.folderGrouping = false,
this.instantMix = false,
});
/// Defaults for a fully-featured Plex server.
static const ServerCapabilities plex = ServerCapabilities(
liveTv: true,
liveTvDvr: true,
videoTranscoding: true,
richHubs: true,
numericUserRating: true,
userFavorites: false,
continueWatchingRemoval: true,
externalSubtitleSearch: true,
richMetadataEdit: true,
scrubThumbnails: true,
folderGrouping: true,
instantMix: true,
);
/// Defaults for a Jellyfin server.
///
/// `videoTranscoding` is `true` — `JellyfinClient.getPlaybackInitialization`
/// negotiates via `POST /Items/{id}/PlaybackInfo` and uses the server's
/// `TranscodingUrl` when a non-original quality preset is selected.
///
/// `liveTv` is `true` because Jellyfin exposes `/LiveTv/Channels` and
/// `/LiveTv/Programs`. Detection + channel listing are wired today;
/// EPG and tuning are follow-ups.
static const ServerCapabilities jellyfin = ServerCapabilities(
liveTv: true,
liveTvDvr: false,
videoTranscoding: true,
richHubs: false,
numericUserRating: false,
userFavorites: true,
externalSubtitleSearch: false,
richMetadataEdit: true,
scrubThumbnails: true,
folderGrouping: true,
instantMix: true,
);
/// Defaults for an Emby server.
///
/// `continueWatchingRemoval` is the one flag where Emby is ahead of Jellyfin:
/// `POST /Users/{uid}/Items/{id}/HideFromResume` drops an item from Continue
/// Watching while keeping its resume position, and Jellyfin 10.11 has no
/// equivalent route.
///
/// Otherwise identical to [jellyfin] except [scrubThumbnails]: seek-bar
/// previews come
/// from Jellyfin's `/Videos/{id}/Trickplay` sprite sheets, which Emby (the
/// pre-fork ancestor) never gained — it 404s and never populates the
/// `Trickplay` item field. With the flag off the player never attempts the
/// load; see [MediaBrowserDialect.supportsTrickplay].
///
/// Emby does expose two *other* preview transports, `/Videos/{id}/index.bif`
/// (the same BIF format Plex uses, so [BifThumbnailService] could parse it)
/// and `/Items/{id}/ThumbnailSet`. Neither is wired: on Emby 4.9.5 both
/// answer 200 with an empty payload — a 72-byte header-only BIF and
/// `{"Thumbnails": []}` — even after a full metadata+image refresh, because
/// Emby only fills them once its own extraction task has run. Wiring them
/// needs a server that has actually generated the frames, so the flag stays
/// `false` rather than shipping a path that cannot be verified.
static const ServerCapabilities emby = ServerCapabilities(
liveTv: true,
liveTvDvr: false,
videoTranscoding: true,
richHubs: false,
numericUserRating: false,
userFavorites: true,
continueWatchingRemoval: true,
externalSubtitleSearch: false,
richMetadataEdit: true,
scrubThumbnails: false,
folderGrouping: true,
instantMix: true,
);
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
/// which Plex probes per server (`PlexClient.capabilities`) — so that is the
/// only override this type needs. Widen the parameter list if another flag
/// ever becomes a runtime probe.
ServerCapabilities copyWith({bool? videoTranscoding}) {
return ServerCapabilities(
liveTv: liveTv,
liveTvDvr: liveTvDvr,
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
richHubs: richHubs,
numericUserRating: numericUserRating,
userFavorites: userFavorites,
continueWatchingRemoval: continueWatchingRemoval,
externalSubtitleSearch: externalSubtitleSearch,
richMetadataEdit: richMetadataEdit,
scrubThumbnails: scrubThumbnails,
folderGrouping: folderGrouping,
instantMix: instantMix,
);
}
}