Files
plezy/lib/media/server_capabilities.dart
T
edde746 a11da3fec8 feat(livetv): record live TV on Jellyfin and Emby via the timer APIs
Jellyfin and Emby users had no way to record live TV from Plezy at all: no
record button in the EPG guide's program menu, and nothing in the recordings
UI, because every recording surface was gated behind a Plex-only DVR adapter.

The recording surfaces are now backed by a MediaBrowser DVR adapter that
synthesizes the neutral (Plex-shaped) payload models from the timer APIs:

- Template comes from `GET /LiveTv/Timers/Defaults?programId=`, offering a
  "Record Episode" entry and, for series airings, "Record Series". The
  defaults DTO travels JSON-encoded in the template's opaque `parameters`
  and setting ids are DTO field names, so the existing template-driven
  record-options sheet renders and round-trips them unchanged.
- Create POSTs the mutated defaults whole to `/LiveTv/Timers` or
  `/LiveTv/SeriesTimers`; a duplicate one-off create answers 400, which the
  adapter rethrows as `RecordingConflictException` so the sheet can show
  "Already scheduled" without a backend check.
- Scheduled recordings read `/LiveTv/Timers` minus `Cancelled`/`Completed`
  tombstones, rules read `/LiveTv/SeriesTimers` with their child timers
  nested. Rule keys carry a `timer:`/`series:` prefix so cancel, edit and
  delete dispatch to the right timer space inside the adapter.

Guide programs now carry their recording state: the program id doubles as the
record seed, and `TimerId`/`SeriesTimerId` become the rule keys that drive the
guide's scheduled dot and the Manage action. The series key is stamped only
when an airing actually records, so an episode a series rule skips does not
show a false indicator.

Three neutrality fixes on the shared UI: the record-options sheet only demands
a target library when the template declares one (MediaBrowser records into its
own configured folder), and "Re-evaluate rules" is hidden unless a connected
DVR supports it, since only Plex has that endpoint.

`fetchDvrs` deliberately stays empty so the synthesized per-server Live TV
identity that channel fetches, favorites and playback key off is preserved.

Verified end-to-end against a disposable jellyfin/jellyfin:10.11.11 container
with an M3U tuner and XMLTV guide, driven through the real client: template,
create, duplicate conflict, cancel, series create with child grabs, edit
round-trip and delete.

close #1645
2026-08-24 00:08:11 +02:00

168 lines
6.5 KiB
Dart
Raw 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;
/// Backend has a recording/DVR API wired in this app. Channel listing is
/// gated by [liveTv]; this flag enables the additional recordings/scheduling
/// UI. Plex serves `/media/subscriptions`; Jellyfin and Emby adapt
/// `/LiveTv/Timers` + `/LiveTv/SeriesTimers`.
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 0–10 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`; `liveTvDvr` rides the timer APIs
/// (`/LiveTv/Timers`, `/LiveTv/SeriesTimers`).
static const ServerCapabilities jellyfin = ServerCapabilities(
liveTv: true,
liveTvDvr: true,
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.
///
/// Scrub thumbnails take a different transport than Jellyfin's: Emby has no
/// `Trickplay` item field or sprite-sheet route, so the player loads a
/// Roku-format BIF from `/Videos/{id}/index.bif` instead — the same wire
/// format Plex serves, parsed by the same `BifThumbnailService`. Emby only
/// fills the endpoint once its own preview-extraction task has run; a server
/// that has not generated frames answers with a header-only BIF, which
/// parses to zero frames and keeps the seek-bar tooltip suppressed.
static const ServerCapabilities emby = ServerCapabilities(
liveTv: true,
liveTvDvr: true,
videoTranscoding: true,
richHubs: false,
numericUserRating: false,
userFavorites: true,
continueWatchingRemoval: true,
externalSubtitleSearch: false,
richMetadataEdit: true,
scrubThumbnails: true,
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,
);
}
}