793 lines
38 KiB
Dart
793 lines
38 KiB
Dart
import '../exceptions/media_server_exceptions.dart';
|
||
import '../media/media_source_info.dart';
|
||
import '../media/media_sort.dart';
|
||
import '../services/api_cache.dart';
|
||
import '../services/playback_initialization_types.dart';
|
||
import '../utils/app_logger.dart';
|
||
import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse, throwIfHttpError;
|
||
import '../utils/external_ids.dart';
|
||
import '../utils/watch_state_notifier.dart';
|
||
import 'download_resolution.dart';
|
||
import 'ids.dart';
|
||
import 'library_filter_result.dart';
|
||
import 'library_first_character.dart';
|
||
import 'library_query.dart';
|
||
import 'live_tv_support.dart';
|
||
import 'lyrics.dart';
|
||
import 'media_backend.dart';
|
||
import 'media_file_info.dart';
|
||
import 'media_hub.dart';
|
||
import '../services/scrub_preview_source.dart';
|
||
import 'media_item.dart';
|
||
import 'media_kind.dart';
|
||
import 'media_library.dart';
|
||
import 'media_playlist.dart';
|
||
import 'playback_report_metadata.dart';
|
||
import 'server_capabilities.dart';
|
||
|
||
/// Default number of items requested for horizontal hub previews.
|
||
const int defaultHubPreviewLimit = 20;
|
||
|
||
/// Backend-neutral client for a single media server (Plex or Jellyfin).
|
||
///
|
||
/// Each implementation wraps the per-backend HTTP layer and exposes the same
|
||
/// operations the rest of the app needs to browse libraries, mark watch
|
||
/// state, and render items. Concrete classes ([PlexClient], `JellyfinClient`)
|
||
/// own the per-backend networking — providers and UI consume them only
|
||
/// through this interface.
|
||
///
|
||
/// ## Naming
|
||
///
|
||
/// Read methods use a `fetch*` prefix. Backend-specific operations that do not
|
||
/// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata
|
||
/// edit adapters) live on concrete clients or feature modules.
|
||
///
|
||
/// ## Mutation error and result contracts
|
||
///
|
||
/// HTTP status, timeout, connection, decode, and cancellation failures from
|
||
/// the shared transport surface as [MediaServerHttpException]. Calls for an
|
||
/// unsupported advertised capability may throw [UnsupportedError] where the
|
||
/// method documents that boundary.
|
||
///
|
||
/// Result semantics follow each method's declared family. Completion of a
|
||
/// `Future<void>` mutation is success and carries no business-result value.
|
||
/// Nullable creation methods return `null` only after an accepted request
|
||
/// produced no usable created entity or id; request failures throw. Boolean
|
||
/// mutations return `true` on their accepted success path, and return `false`
|
||
/// only for local preconditions explicitly documented by that method rather
|
||
/// than as a substitute for request failure.
|
||
///
|
||
/// `fetchItem` returns `null` on a real 404 (item gone) and on a 200 that
|
||
/// can't be parsed; auth/server errors throw rather than silently dropping
|
||
/// to `null`.
|
||
|
||
/// Outcome of a health probe. Distinguishes "session expired" (token was
|
||
/// rejected) from a generic transport failure, so the manager can route the
|
||
/// two states to different UI ("Sign in again" vs "Server offline").
|
||
enum HealthStatus { online, offline, authError }
|
||
|
||
abstract interface class GracefullyCloseable {
|
||
Future<void> closeGracefully({Duration drainTimeout});
|
||
}
|
||
|
||
abstract class MediaServerClient {
|
||
ServerId get serverId;
|
||
String? get serverName;
|
||
MediaBackend get backend;
|
||
ServerCapabilities get capabilities;
|
||
|
||
/// Release HTTP resources and any other long-lived state. Idempotent.
|
||
void close();
|
||
|
||
/// Probe the server with a lightweight auth-required round-trip and
|
||
/// classify the outcome. Implementations must surface 401/403 as
|
||
/// [HealthStatus.authError] so the manager can flag a revoked token
|
||
/// distinctly from a generic network failure.
|
||
Future<HealthStatus> checkHealth();
|
||
|
||
/// Convenience predicate over [checkHealth] for callers that only need a
|
||
/// boolean. Treats both `offline` and `authError` as unhealthy.
|
||
Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;
|
||
|
||
/// Server-reported unique identifier (Plex `machineIdentifier`,
|
||
/// Jellyfin `Id`). Returns `null` if the probe fails.
|
||
Future<String?> getMachineIdentifier();
|
||
|
||
/// When `true`, the client serves cached responses only and never hits the
|
||
/// network.
|
||
bool get isOfflineMode;
|
||
void setOfflineMode(bool offline);
|
||
|
||
/// Backend-specific cache substrate. Subclasses override this so the
|
||
/// shared [MediaServerCacheMixin] helpers can read and write through the
|
||
/// appropriate cache instance.
|
||
ApiCache get cache;
|
||
|
||
Future<List<MediaLibrary>> fetchLibraries();
|
||
|
||
/// Page through items in [libraryId] using the neutral [query]. Backends
|
||
/// translate sort/filter clauses into their own DSL.
|
||
Future<LibraryPage<MediaItem>> fetchLibraryContent(String libraryId, LibraryQuery query);
|
||
|
||
/// Backend-aware paginated content fetch.
|
||
///
|
||
/// Pagination lives on [LibraryQuery.offset] / [LibraryQuery.limit].
|
||
/// [libraryKind] disambiguates a Jellyfin "Shows" library so it returns
|
||
/// Series rows rather than the recursive episode expansion the server
|
||
/// defaults to. Plex ignores it (the section id already pins the type).
|
||
///
|
||
/// The previous `plexStyleFilters: Map<String,String>` parameter was
|
||
/// retired — the library UI now builds a neutral [LibraryQuery] at the
|
||
/// call boundary via `libraryQueryFromPlexMap`, and the Plex client
|
||
/// translates back to wire params via [PlexLibraryQueryTranslator].
|
||
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
|
||
String libraryId, {
|
||
required LibraryQuery query,
|
||
MediaKind? libraryKind,
|
||
AbortController? abort,
|
||
});
|
||
|
||
/// Filter categories for [libraryId] plus any values the backend serves
|
||
/// up-front. Plex returns categories without values (the FiltersBottomSheet
|
||
/// fetches values lazily per category); Jellyfin returns both in a single
|
||
/// `/Items/Filters` call and pre-populates [LibraryFilterResult.cachedValues].
|
||
/// [libraryKind] lets backends use media-appropriate labels for synthetic
|
||
/// filters. Backends that have no filter listing return
|
||
/// [LibraryFilterResult.empty].
|
||
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind});
|
||
|
||
/// Backend-aware sort options for [libraryId]. Plex hits
|
||
/// `/library/sections/{id}/sorts`; Jellyfin returns a hardcoded list
|
||
/// (the API has no equivalent endpoint). Returns [] when the server
|
||
/// has no opinion. [libraryType] disambiguates Plex's per-type sort
|
||
/// lists (movie vs show).
|
||
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType});
|
||
|
||
/// First-character bucket counts for the alpha-jump bar in the library
|
||
/// browse view. Plex returns real counts from
|
||
/// `/library/sections/{id}/firstCharacter` (filterable); Jellyfin has no
|
||
/// equivalent endpoint and synthesises a 27-letter alphabet so the bar
|
||
/// can act as a name-prefix filter (`size: 1` per entry).
|
||
Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters});
|
||
|
||
/// Queue a metadata refresh for [libraryId]. The id is the backend-native
|
||
/// library identifier (Plex section id / Jellyfin view item id, both
|
||
/// surfaced via [MediaLibrary.key]). Plex hits
|
||
/// `/library/sections/{id}/refresh?force=1`; Jellyfin posts to
|
||
/// `/Items/{id}/Refresh` with `metadataRefreshMode=FullRefresh` (the
|
||
/// library view is itself a Jellyfin item, and refresh recurses into its
|
||
/// children).
|
||
Future<void> refreshLibraryMetadata(String libraryId);
|
||
|
||
/// Fetch a single item by its backend-opaque id. Returns `null` when the
|
||
/// item no longer exists or the user can't see it.
|
||
Future<MediaItem?> fetchItem(String id);
|
||
|
||
/// Fetch a single item *and* its on-deck episode (the next unwatched /
|
||
/// in-progress episode) in one round-trip when the backend supports it.
|
||
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
|
||
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
|
||
/// leaving callers to fetch on-deck separately if they need it.
|
||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id);
|
||
|
||
/// Direct children of [parentId] — episodes of a season, seasons of a
|
||
/// show, tracks of an album, items of a collection.
|
||
Future<List<MediaItem>> fetchChildren(String parentId);
|
||
|
||
/// Top-level rows of a library in folder-browsing mode. Directory rows come
|
||
/// back as [MediaKind.folder] (Plex additionally stamps
|
||
/// [MediaItem.backendFolderKey]). [onPage] surfaces accumulated items after
|
||
/// each intermediate page on backends that paginate (Jellyfin); single-shot
|
||
/// backends (Plex) never call it.
|
||
Future<List<MediaItem>> fetchLibraryFolders(String libraryId, {void Function(List<MediaItem> itemsSoFar)? onPage});
|
||
|
||
/// Children of a [MediaKind.folder] row from [fetchLibraryFolders] /
|
||
/// [fetchFolderChildren] — or, on Jellyfin, of a show/season row, which act
|
||
/// as expandable folders in folder browsing. Same [onPage] semantics as
|
||
/// [fetchLibraryFolders].
|
||
Future<List<MediaItem>> fetchFolderChildren(
|
||
MediaItem folder, {
|
||
String? libraryId,
|
||
String? libraryTitle,
|
||
void Function(List<MediaItem> itemsSoFar)? onPage,
|
||
});
|
||
|
||
/// Page through direct children of [parentId]. Unlike
|
||
/// [fetchPlayableDescendantsPage], this preserves container shape and is used
|
||
/// for large season episode lists where the children endpoint is the correct
|
||
/// backend primitive.
|
||
Future<LibraryPage<MediaItem>> fetchChildrenPage(String parentId, {int? start, int? size, AbortController? abort});
|
||
|
||
/// Page through playable descendants of [parentId]. Used by flattened show /
|
||
/// season detail views so episodes can render before the full list has loaded.
|
||
/// [fetchPlayableDescendants] remains the complete-list helper for
|
||
/// playback/download/sync paths.
|
||
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
|
||
String parentId, {
|
||
int? start,
|
||
int? size,
|
||
AbortController? abort,
|
||
});
|
||
|
||
/// Playable descendants of [parentId] in one server-side query — for a
|
||
/// show this returns every episode across every season; for a season the
|
||
/// same episodes as [fetchChildren]; on Jellyfin a collection/playlist
|
||
/// expands to its Movies + Episodes (Series containers are skipped).
|
||
/// Used by playback launch (Jellyfin only — Plex routes containers
|
||
/// through `/playQueues`) and by bulk download/sync (both backends) so
|
||
/// neither has to walk show → seasons → episodes itself, and neither
|
||
/// inherits a per-page Limit cap.
|
||
///
|
||
/// Plex hits `/library/metadata/{id}/grandchildren` (the only endpoint
|
||
/// the server will one-shot for both show and season, and the
|
||
/// recommended path for `skipChildren=true` mini-series); Jellyfin hits
|
||
/// `/Items?ParentId={id}&Recursive=true&IncludeItemTypes=Movie,Episode`.
|
||
/// Plex's choice means a *collection* ratingKey is not currently
|
||
/// supported (no Plex consumer needs that today); add a kind-specific
|
||
/// branch if/when one does.
|
||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId);
|
||
|
||
/// All episodes of a series across every season in playback order. Used
|
||
/// to build a local navigation queue for backends without server-side
|
||
/// queues and as a fallback when a server-side queue could not be created.
|
||
/// Returns null only when a backend cannot supply a client-side queue;
|
||
/// an empty list means the series itself was empty.
|
||
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId);
|
||
|
||
/// Albums credited to [artist], newest first. Plex filters album rows in
|
||
/// the artist's music section so release formats omitted from
|
||
/// `/library/metadata/{id}/children` remain visible. Jellyfin links albums
|
||
/// to artists via tags and queries
|
||
/// `/Items?AlbumArtistIds={id}&IncludeItemTypes=MusicAlbum`.
|
||
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist);
|
||
|
||
/// Tracks of album [albumId] in disc/track order. Plex:
|
||
/// `/library/metadata/{id}/children`; Jellyfin:
|
||
/// `/Items?AlbumIds={id}&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber`
|
||
/// (AlbumIds rather than ParentId so tag-based albums whose files share one
|
||
/// physical folder still resolve).
|
||
Future<List<MediaItem>> fetchAlbumTracks(String albumId);
|
||
|
||
/// Server-built "instant mix" / radio track list seeded from [itemId]
|
||
/// (track, album, artist, or playlist). Jellyfin:
|
||
/// `/Items/{id}/InstantMix`; Plex: a station play queue
|
||
/// (`POST /playQueues?type=audio&uri=...station...`), consumed here as a
|
||
/// plain track list — music playback is queue-managed client-side on both
|
||
/// backends. Gated by [ServerCapabilities.instantMix].
|
||
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100});
|
||
|
||
/// Lyrics for [track], or `null` when the server has none. Jellyfin:
|
||
/// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a
|
||
/// sidecar-lyrics track stream (`streamType 4`) fetched from
|
||
/// `/library/streams/{id}` and parsed from LRC. Synced-ness is per
|
||
/// [Lyrics.synced]; gated by [ServerCapabilities.lyrics].
|
||
Future<Lyrics?> fetchLyrics(MediaItem track);
|
||
|
||
/// Free-text search across the user's libraries.
|
||
Future<List<MediaItem>> searchItems(String query, {int limit = 100});
|
||
|
||
/// Recently-added items across all libraries.
|
||
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
|
||
|
||
/// Items the user has started but not finished. Plex calls this "On Deck"
|
||
/// internally; the neutral name matches the Continue Watching UI surface.
|
||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20});
|
||
|
||
/// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin
|
||
/// synthesizes `Latest` plus optional `Resume` + `NextUp`).
|
||
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true});
|
||
|
||
/// Hubs scoped to a single library section. [libraryName] is baked into
|
||
/// the title of synthetic hubs (Jellyfin) so per-library "Recently Added"
|
||
/// / "Next Up" hubs aren't all identically named on the home screen.
|
||
/// [includePlaybackHubs] lets surfaces that already render Continue
|
||
/// Watching skip duplicate playback rows. [libraryKind] lets backends avoid
|
||
/// irrelevant expensive probes, e.g. Jellyfin `NextUp` for movie libraries.
|
||
Future<List<MediaHub>> fetchLibraryHubs(
|
||
String libraryId, {
|
||
required String libraryName,
|
||
int limit = defaultHubPreviewLimit,
|
||
bool includePlaybackHubs = true,
|
||
MediaKind? libraryKind,
|
||
});
|
||
|
||
/// "More like this" recommendations for [id].
|
||
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10});
|
||
|
||
/// Playable extras attached to [id] (trailers, featurettes, deleted scenes,
|
||
/// behind-the-scenes clips). Backends return only items that can be opened by
|
||
/// the normal video playback flow; external/remote trailer URLs are out of
|
||
/// scope for this neutral surface.
|
||
Future<List<MediaItem>> fetchExtras(String id);
|
||
|
||
/// Media featuring a specific person/actor.
|
||
Future<List<MediaItem>> fetchPersonMedia(String personId);
|
||
|
||
/// Page through media featuring a specific person/actor.
|
||
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(String personId, {int? start, int? size, AbortController? abort});
|
||
|
||
/// Page through items in [hubId] when the hub previewed only the first N
|
||
/// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same
|
||
/// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query
|
||
/// (Latest / Resume / NextUp) without the preview limit.
|
||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit});
|
||
|
||
/// Page through expanded hub content where the backend exposes a paged
|
||
/// endpoint. Backends without true hub pagination may return a single page.
|
||
Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(String hubId, {int? start, int? size, AbortController? abort});
|
||
|
||
/// Mark [item] as watched. Transport only: no [WatchStateEvent] is emitted
|
||
/// here — UI surfaces go through `WatchActions`, which owns the single
|
||
/// emission (and tracker fan-out); the offline sync replay calls this
|
||
/// directly precisely because the event already fired when the action was
|
||
/// queued.
|
||
Future<void> markWatched(MediaItem item);
|
||
|
||
/// Mark [item] as unwatched. Transport only — see [markWatched].
|
||
Future<void> markUnwatched(MediaItem item);
|
||
|
||
/// Hide an item from Continue Watching without changing watched status or
|
||
/// progress. Only call when [capabilities.continueWatchingRemoval] is true;
|
||
/// unsupported backends throw [UnsupportedError].
|
||
Future<void> removeFromContinueWatching(MediaItem item);
|
||
|
||
/// Rate the item on a 0–10 scale. Backends without numeric ratings
|
||
/// (Jellyfin) collapse to like/dislike — see [ServerCapabilities.numericUserRating].
|
||
/// Throws [MediaServerHttpException] on failure, mirroring [markWatched] /
|
||
/// [markUnwatched] / [removeFromContinueWatching] — callers wrap the
|
||
/// awaited call in `try/catch` and surface a snackbar on the catch arm.
|
||
Future<void> rate(MediaItem item, double rating);
|
||
|
||
/// Set or clear the per-user favorite flag ("heart") for [item]. Only call
|
||
/// when [ServerCapabilities.userFavorites] is true; unsupported backends
|
||
/// throw [UnsupportedError]. Throws [MediaServerHttpException] on failure.
|
||
Future<void> setFavorite(MediaItem item, bool isFavorite);
|
||
|
||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart});
|
||
|
||
/// Page through server playlists. Used by the library Playlists tab so large
|
||
/// servers can render incrementally; [fetchPlaylists] remains the complete
|
||
/// list helper for dialogs and bulk operations.
|
||
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
||
String playlistType = 'video',
|
||
bool? smart,
|
||
int? start,
|
||
int? size,
|
||
AbortController? abort,
|
||
});
|
||
|
||
/// Metadata only — items are fetched via [fetchPlaylistItems].
|
||
Future<MediaPlaylist?> fetchPlaylistMetadata(String id);
|
||
|
||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100});
|
||
|
||
/// Page through items in [id]. Backends preserve playlist order and include
|
||
/// per-playlist item ids where the server exposes them.
|
||
Future<LibraryPage<MediaItem>> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort});
|
||
|
||
/// Create a new playlist seeded with [items]. Returns the created playlist
|
||
/// when it can be recovered from an accepted response, or `null` when that
|
||
/// response contains no usable created playlist. Request failures throw.
|
||
/// Plex builds a metadata URI from the item ids; Jellyfin posts
|
||
/// `Ids=<comma-joined>`.
|
||
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items});
|
||
|
||
/// Append [items] to an existing playlist. Returns `true` on success.
|
||
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items});
|
||
|
||
/// Delete [playlist] from the server. Returns `true` on success.
|
||
Future<bool> deletePlaylist(MediaPlaylist playlist);
|
||
|
||
/// Move an item to a new position within a playlist. The item must have come
|
||
/// from this client's [fetchPlaylistItems] (i.e. carry a per-playlist id).
|
||
///
|
||
/// [newIndex] - 0-based target position after the move
|
||
/// [afterItem] - the item that should sit immediately before [item] after
|
||
/// the move, or null when [newIndex] == 0. Plex uses this to derive its
|
||
/// `?after=` query param; Jellyfin ignores it (it takes an absolute index).
|
||
///
|
||
/// Returns `false` (without throwing) if [item] is from the wrong backend
|
||
/// or is missing its per-playlist id — callers should surface a snackbar.
|
||
Future<bool> movePlaylistItem({
|
||
required String playlistId,
|
||
required MediaItem item,
|
||
required int newIndex,
|
||
required MediaItem? afterItem,
|
||
});
|
||
|
||
/// Remove [item] from the playlist [playlistId]. See [movePlaylistItem] for
|
||
/// the same caveats about backend tagging and the per-playlist id.
|
||
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item});
|
||
|
||
/// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`;
|
||
/// Jellyfin resolves its top-level `boxsets` view and walks that root in
|
||
/// bounded pages.
|
||
/// Each result carries `kind == MediaKind.collection`.
|
||
Future<List<MediaItem>> fetchCollections(String libraryId);
|
||
|
||
/// Page through collections in [libraryId]. Used by the library Collections
|
||
/// tab so large Jellyfin/Plex servers can render incrementally while the
|
||
/// user scrolls. [fetchCollections] remains the complete-list helper for
|
||
/// dialogs and bulk operations.
|
||
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
||
String libraryId, {
|
||
int? start,
|
||
int? size,
|
||
AbortController? abort,
|
||
});
|
||
|
||
/// Page through items in [collectionId]. Plex paginates server-side via
|
||
/// `/library/collections/{id}/children`; Jellyfin uses `/Items` with
|
||
/// `ParentId`, `StartIndex`, and `Limit`. Callers can rely on
|
||
/// [LibraryPage.totalCount] either way.
|
||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||
String collectionId, {
|
||
int? start,
|
||
int? size,
|
||
AbortController? abort,
|
||
String? libraryId,
|
||
String? libraryTitle,
|
||
});
|
||
|
||
/// Create a new collection in [libraryId] seeded with [items]. Returns the
|
||
/// created collection id when it can be recovered from an accepted response,
|
||
/// or `null` when that response contains no usable id. Request failures
|
||
/// throw. [itemKind] is only used by Plex (it disambiguates the section type
|
||
/// — movie/show/season/episode); Jellyfin ignores it.
|
||
Future<String?> createCollection({
|
||
required String libraryId,
|
||
required String title,
|
||
required List<MediaItem> items,
|
||
MediaKind? itemKind,
|
||
});
|
||
|
||
/// Append [items] to an existing collection.
|
||
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items});
|
||
|
||
/// Remove a single [item] from [collectionId].
|
||
Future<bool> removeFromCollection({required String collectionId, required MediaItem item});
|
||
|
||
/// Delete a collection from the server. The collection is passed as a
|
||
/// [MediaItem] (kind == [MediaKind.collection]) so the implementation can
|
||
/// read [MediaItem.libraryId] for backends that need it (Plex).
|
||
Future<bool> deleteCollection(MediaItem collection);
|
||
|
||
/// Permanently delete [item] from the library.
|
||
Future<bool> deleteMediaItem(MediaItem item);
|
||
|
||
/// File info (codec / resolution / bitrate / file path) for [item].
|
||
/// Plex round-trips `/library/metadata/{id}` for the full set; Jellyfin
|
||
/// reads inline `MediaSources` for the subset it has. Returns `null` if
|
||
/// the server has no info to show.
|
||
Future<MediaFileInfo?> getFileInfo(MediaItem item);
|
||
|
||
/// Resolve a backend-relative thumbnail path to a fully-qualified URL ready
|
||
/// for cached image providers. Returns an empty string for null/empty
|
||
/// inputs.
|
||
///
|
||
/// When [width]/[height] are provided, the implementation should request
|
||
/// a server-side resize: Plex builds a `/photo/:/transcode` URL; Jellyfin
|
||
/// appends `MaxWidth`/`MaxHeight` to the image endpoint.
|
||
String thumbnailUrl(String? path, {int? width, int? height});
|
||
|
||
/// Proxy an absolute external image URL through the server's transcoder
|
||
/// (Plex `/photo/:/transcode?url=...`). Backends without a proxy endpoint
|
||
/// (Jellyfin) should return the URL unchanged. Used for EPG provider art
|
||
/// and other off-server images that benefit from re-encoding.
|
||
String externalImageUrl(String url, {int? width, int? height});
|
||
|
||
/// Headers that must be attached when the player fetches a direct-play
|
||
/// URL from this server. Plex requires `X-Plex-Token` (and identity
|
||
/// headers); Jellyfin embeds its `api_key` in the query string and
|
||
/// returns an empty map. Player code should pass these through to the
|
||
/// engine alongside the URL.
|
||
Map<String, String> get streamHeaders;
|
||
|
||
/// External IDs (IMDb / TMDB / TVDB) for [itemId]. Plex hits
|
||
/// `/library/metadata/{id}?includeGuids=1`; Jellyfin reads the inline
|
||
/// `ProviderIds` map. Returns an empty [ExternalIds] when the server
|
||
/// has no external mapping for the item.
|
||
Future<ExternalIds> fetchExternalIds(String itemId);
|
||
|
||
/// Reverse lookup: find a library movie/show matching any of [ids].
|
||
/// Both backends search by [title] (narrowed by a ±1 [year] window when
|
||
/// known, with an unfiltered fallback) and verify candidates against
|
||
/// their exact external ids. Plex checks its modern `Guid` array first,
|
||
/// then recognized legacy scalar `guid` formats; its `guid=` field filter
|
||
/// only matches the primary `plex://` guid. Jellyfin checks the inline
|
||
/// `ProviderIds`. False negatives remain possible on differing titles,
|
||
/// but title alone never produces a match. Returns null when this server
|
||
/// has no match or [kind] is not movie/show. Used to match external catalog
|
||
/// items (Explore tab) back to the user's libraries.
|
||
Future<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year});
|
||
|
||
/// Chapters and intro/credits markers for [itemId]. Plex returns both in one
|
||
/// round trip; Jellyfin combines item-level chapters with best-effort native
|
||
/// media segments. Implementations may cache.
|
||
Future<PlaybackExtras> fetchPlaybackExtras(
|
||
String itemId, {
|
||
String? introPattern,
|
||
String? creditsPattern,
|
||
bool forceChapterFallback = false,
|
||
bool forceRefresh = false,
|
||
});
|
||
|
||
/// Cache-only [PlaybackExtras] read for [itemId]. Used as the offline
|
||
/// fallback when [fetchPlaybackExtras] cannot reach the network. Returns
|
||
/// `null` when no row is cached or the row carries no chapter/marker
|
||
/// data — callers treat that as "no extras available" without surfacing
|
||
/// an error.
|
||
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
|
||
String itemId, {
|
||
String? introPattern,
|
||
String? creditsPattern,
|
||
bool forceChapterFallback = false,
|
||
});
|
||
|
||
/// Cache-only [MediaSourceInfo] read for [itemId]. Used by the offline
|
||
/// playback path to recover audio/subtitle track info (track ids, language
|
||
/// codes, displayTitles) without hitting the network. Returns `null` when
|
||
/// the row isn't cached or carries no usable media source.
|
||
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);
|
||
|
||
/// Build a scrub preview source for [item] using [mediaSource]. Plex
|
||
/// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
|
||
/// reader from the trickplay manifest. Returns `null` when scrub
|
||
/// previews aren't available for this item — either because the
|
||
/// backend doesn't advertise the capability, or the per-item inputs
|
||
/// are missing (Plex needs `partId`, Jellyfin needs a non-empty
|
||
/// `trickplayByWidth` map).
|
||
Future<ScrubPreviewSource?> createScrubPreviewSource({required MediaItem item, required MediaSourceInfo mediaSource});
|
||
|
||
/// Watched threshold (0.0–1.0). An item is considered "watched" when
|
||
/// `position / duration` crosses this value. Plex reads it from the
|
||
/// server's `LibraryVideoPlayedThreshold` pref; Jellyfin doesn't expose
|
||
/// one and returns a fixed 0.9.
|
||
double get watchedThreshold;
|
||
|
||
/// Whether a playback-stopped report past [watchedThreshold] already marks
|
||
/// the item played server-side. When true, in-player auto-scrobble must NOT
|
||
/// also call [markWatched]: the server marks it played from the stop report,
|
||
/// and the extra `/UserPlayedItems` toggle double-scrobbles through
|
||
/// integrations that watch both the played-state change and the
|
||
/// playback-stop — e.g. Jellyfin's Trakt plugin fires once on `TogglePlayed`
|
||
/// and again on `PlayedToCompletion` (#1287). Plex returns false: its
|
||
/// timeline stop doesn't reliably mark watched without an active play
|
||
/// session, so the explicit call is still required.
|
||
bool get marksWatchedOnPlaybackStopped;
|
||
|
||
/// First playback signal for [itemId]. Plex sends a `/:/timeline?state=playing`
|
||
/// heartbeat; Jellyfin opens a `/Sessions/Playing` session row. Subsequent
|
||
/// ticks must call [reportPlaybackProgress] (Jellyfin distinguishes session
|
||
/// open from progress; Plex treats them identically).
|
||
///
|
||
/// [duration] is the media's total length — passed through to Plex's
|
||
/// timeline param so the server can use it. Jellyfin ignores [duration] but
|
||
/// uses [mediaSourceId], [liveStreamId], and stream indexes for active-session
|
||
/// state. Jellyfin needs [liveStreamId] to close an auto-opened live source.
|
||
Future<void> reportPlaybackStarted({
|
||
required String itemId,
|
||
required Duration position,
|
||
Duration? duration,
|
||
String? playSessionId,
|
||
String? playMethod,
|
||
String? liveStreamId,
|
||
String? mediaSourceId,
|
||
int? audioStreamIndex,
|
||
int? subtitleStreamIndex,
|
||
});
|
||
|
||
/// Progress heartbeat after [reportPlaybackStarted]. State is derived from
|
||
/// [isPaused]. Jellyfin persists remembered audio/subtitle choices from the
|
||
/// selected stream indexes on this call.
|
||
Future<void> reportPlaybackProgress({
|
||
required String itemId,
|
||
required Duration position,
|
||
required Duration duration,
|
||
bool isPaused = false,
|
||
String? playSessionId,
|
||
String? playMethod,
|
||
String? liveStreamId,
|
||
String? mediaSourceId,
|
||
int? audioStreamIndex,
|
||
int? subtitleStreamIndex,
|
||
});
|
||
|
||
/// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes
|
||
/// the session row. [report] carries semantic metadata such as offline
|
||
/// replay timing without leaking backend-specific wire parameter names.
|
||
Future<void> reportPlaybackStopped({
|
||
required String itemId,
|
||
required Duration position,
|
||
Duration? duration,
|
||
String? playSessionId,
|
||
String? liveStreamId,
|
||
String? mediaSourceId,
|
||
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
||
});
|
||
|
||
/// Resolve the video URL, media info, and external subtitle list for
|
||
/// playback. Backends own the per-backend particulars: Plex runs the
|
||
/// transcode-decision flow for non-original quality; Jellyfin negotiates
|
||
/// both original and non-original playback through PlaybackInfo. Typed
|
||
/// request, cancellation, and malformed-payload failures propagate. Only an
|
||
/// applicable successful decision may select a direct-play fallback.
|
||
/// Unusable successful playback metadata throws [PlaybackException].
|
||
///
|
||
/// Offline-file substitution is handled centrally in
|
||
/// `PlaybackInitializationService` — backends always produce online
|
||
/// metadata, even when the caller intends to play a downloaded copy.
|
||
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options);
|
||
|
||
/// Backend-neutral live-TV operations. Always returns a wrapper; consult
|
||
/// [LiveTvSupport.isAvailable] to find out whether the server actually
|
||
/// has live TV configured before calling other methods. Recording and DVR
|
||
/// administration are available through [MediaServerClientLiveTv.liveTvDvr].
|
||
LiveTvSupport get liveTv;
|
||
|
||
/// Resolve the download URL for [item]'s primary video file along with
|
||
/// any external subtitle tracks that should be saved alongside it.
|
||
///
|
||
/// [mediaIndex] selects among multiple media versions when an item has them.
|
||
///
|
||
/// A successful applicable response may contain no URL. Request,
|
||
/// cancellation, and malformed-payload failures throw rather than returning
|
||
/// a partial resolution.
|
||
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
|
||
|
||
/// The artwork files the download pipeline should persist for [item] so
|
||
/// the offline UI can render its poster, clear logo, and background art.
|
||
/// Each entry pairs the absolute URL with a stable `localKey` the
|
||
/// storage service hashes to deduplicate across items that share blobs.
|
||
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item);
|
||
|
||
/// Resolve a fully-qualified URL the OS-level external player (VLC, Infuse,
|
||
/// MX Player, etc.) can fetch directly. Plex builds this from the chosen
|
||
/// media version's part path; Jellyfin returns its `/Videos/{id}/stream`
|
||
/// endpoint with `Static=true` so transcoding is bypassed. Returns null only
|
||
/// when a successful response has no playable URL for the item. Request,
|
||
/// cancellation, and malformed-payload failures throw.
|
||
///
|
||
/// Deliberately separate from the in-app playback funnel
|
||
/// (`PlaybackSourceResolver`): external players can't send custom headers,
|
||
/// so the URL must be self-contained (token in the query string), and
|
||
/// there's no session/transcode negotiation to carry. Likewise their
|
||
/// progress reporting is a one-shot started/stopped pair in
|
||
/// `ExternalPlayerService` — an external app exposes no live position
|
||
/// stream for the in-player tracker to follow.
|
||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
|
||
}
|
||
|
||
/// Optional interface for backends whose public server id is not specific
|
||
/// enough for user-scoped local state.
|
||
abstract interface class ScopedMediaServerClient {
|
||
String get scopedServerId;
|
||
}
|
||
|
||
extension MediaServerClientScope on MediaServerClient {
|
||
/// Internal cache/sync namespace. Most backends use [serverId]; Jellyfin
|
||
/// overrides this with its compound `{machineId}/{userId}` connection id so
|
||
/// per-user `UserData` and queued progress never bleed across profiles.
|
||
String get cacheServerId => switch (this) {
|
||
ScopedMediaServerClient(:final scopedServerId) => scopedServerId,
|
||
_ => serverId,
|
||
};
|
||
|
||
/// Mark [item] watched because it crossed [watchedThreshold] during playback,
|
||
/// when a playback-stopped report is/was also sent for the same playback.
|
||
/// Backends that mark played from the stop report
|
||
/// ([marksWatchedOnPlaybackStopped]) skip the server call — issuing
|
||
/// [markWatched] too would double-scrobble via the Jellyfin Trakt plugin
|
||
/// (#1287). The single local event emitted here keeps the UI and Plezy's
|
||
/// own Trakt sync (which key on `watched` events, not progress) in sync;
|
||
/// the stop report syncs the server.
|
||
Future<void> markWatchedFromPlaybackStop(MediaItem item) async {
|
||
if (!marksWatchedOnPlaybackStopped) {
|
||
await markWatched(item);
|
||
}
|
||
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
|
||
}
|
||
}
|
||
|
||
extension MediaServerClientLiveTv on MediaServerClient {
|
||
/// Optional recording/admin adapter, gated by the backend capability flag.
|
||
/// Call sites use this rather than assuming every Live TV backend supports
|
||
/// Plex's DVR surface.
|
||
LiveTvDvrSupport? get liveTvDvr => capabilities.liveTvDvr ? liveTv.dvr : null;
|
||
}
|
||
|
||
/// Optional capability for clients that can fetch a season's episodes without
|
||
/// listing generic children. Jellyfin uses this to avoid mixing local extras or
|
||
/// missing/virtual placeholders into normal season episode rails.
|
||
abstract interface class SeasonEpisodePagingClient {
|
||
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
|
||
String seriesId,
|
||
String seasonId, {
|
||
int? start,
|
||
int? size,
|
||
AbortController? abort,
|
||
});
|
||
}
|
||
|
||
/// Cache-aware fetch helpers shared by both backends so the offline-first /
|
||
/// network-then-cache pattern lives in one place.
|
||
///
|
||
/// Originally a Plex-only inline helper; lifted into a mixin so [JellyfinClient]
|
||
/// can stop reimplementing it (and gets the missing "fall back to cache on
|
||
/// non-network errors" branch). Mixed onto concrete [MediaServerClient]
|
||
/// implementations — both clients use `implements MediaServerClient` so a
|
||
/// shared base class isn't an option, but a `mixin on MediaServerClient` is.
|
||
mixin MediaServerCacheMixin implements MediaServerClient {
|
||
/// Fetch with cache fallback: offline → cached only; online → try network,
|
||
/// cache the result, fall back to cached on any error.
|
||
///
|
||
/// Returns `null` when offline mode is on and no cached row exists, or
|
||
/// when both network and cache come up empty.
|
||
Future<T?> fetchWithCacheFallback<T>({
|
||
required String cacheKey,
|
||
required Future<MediaServerResponse> Function() networkCall,
|
||
required T? Function(dynamic cachedData) parseCache,
|
||
required T? Function(MediaServerResponse response) parseResponse,
|
||
bool cacheResponse = true,
|
||
}) async {
|
||
final cacheScope = ServerId(cacheServerId);
|
||
if (isOfflineMode) {
|
||
final cached = await cache.get(cacheScope, cacheKey);
|
||
if (cached != null) return parseCache(cached);
|
||
return null;
|
||
}
|
||
try {
|
||
final response = await networkCall();
|
||
throwIfHttpError(response);
|
||
if (cacheResponse) {
|
||
await _putCacheResponse(cacheScope, cacheKey, response.data);
|
||
}
|
||
return parseResponse(response);
|
||
} catch (e) {
|
||
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
|
||
final cached = await cache.get(cacheScope, cacheKey);
|
||
if (cached != null) return parseCache(cached);
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Cache-first fetch: serve from cache when available, hit the network
|
||
/// only on miss. Use when freshness is non-critical and prior fetches are
|
||
/// likely to have populated the cache (e.g. playback after the detail
|
||
/// screen pre-warmed the row).
|
||
///
|
||
/// [cacheScope] must be captured from the same request context as
|
||
/// [networkCall]. The cache lookup may yield before a miss is known, so
|
||
/// sampling a live profile inside [networkCall] can cross profile identities.
|
||
Future<T?> fetchWithCacheFirst<T>({
|
||
required ServerId cacheScope,
|
||
required String cacheKey,
|
||
required Future<MediaServerResponse> Function() networkCall,
|
||
required T? Function(dynamic cachedData) parseCache,
|
||
required T? Function(MediaServerResponse response) parseResponse,
|
||
bool cacheResponse = true,
|
||
}) async {
|
||
final cached = await cache.get(cacheScope, cacheKey);
|
||
if (cached != null) return parseCache(cached);
|
||
if (isOfflineMode) return null;
|
||
final response = await networkCall();
|
||
throwIfHttpError(response);
|
||
if (cacheResponse) {
|
||
await _putCacheResponse(cacheScope, cacheKey, response.data);
|
||
}
|
||
return parseResponse(response);
|
||
}
|
||
|
||
Future<void> _putCacheResponse(ServerId cacheScope, String cacheKey, dynamic data) async {
|
||
try {
|
||
if (data is Map<String, dynamic>) {
|
||
await cache.put(cacheScope, cacheKey, data);
|
||
} else if (data != null) {
|
||
appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}');
|
||
}
|
||
} catch (e, st) {
|
||
appLogger.w('Cache write failed for $cacheKey', error: e, stackTrace: st);
|
||
}
|
||
}
|
||
}
|