MAL/AniList season entries never matched the library show they belong to. Both backends use the catalog title as a server-side filter before verifying external ids, and a title like "Mushoku Tensei: Jobless Reincarnation Season 2" cannot reach a show stored as "Mushoku Tensei: Jobless Reincarnation". Measured against a 267-show Plex library, 3 of 113 mapped sequel entries matched. The reverse lookup now takes two ordered title candidates instead of one: the entry's own title and its season-stripped form, with typographic punctuation normalised because both servers miss on a curly apostrophe. That matches 77 of 113. Widening it further to romaji/native/synonym variants reached only 81, so the cap stays at two rather than spending up to five more requests per lookup that finds nothing. A sequel's year is its own season's, not the parent show's, so the +/-1 year window is dropped for one - it would exclude the very show being looked for. That also keeps a miss at the two requests the single-title lookup already spent. A Plex Discover item additionally skips the title search entirely by filtering on the plex:// guid its own rating key already is, which costs no extra request and needs no cloud lookup. A season 2+ entry only matches when the server really has that season, which costs one children fetch on a match. Only a season both TVDB and TMDB agree on is gated: which provider a library numbers its seasons by is a server setting no dataset supplies and none of it is inferable from the ids an item exposes, so a disagreeing reference is left ungated rather than gated on a guess. The match cache is keyed per source and per entry rather than by canonical id, which every season of a series shares: all five Mushoku Tensei entries collapse to imdb:tt13293588, so one season-gated result would have poisoned the rest. Entries whose Fribb row carries no provider id at all remain unmatched. That is an upstream mapping gap, not something to guess around with extra lookups. close #1704
845 lines
40 KiB
Dart
845 lines
40 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. An online HTTP 404 returns
|
||
/// `null`; every other HTTP status remains an error. Implementations may use
|
||
/// cached metadata while explicitly offline or after a classified transient
|
||
/// transport failure, but must not turn other online failures into stale
|
||
/// success.
|
||
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.
|
||
/// The item follows [fetchItem]'s error contract: an online HTTP 404 returns
|
||
/// both nullable fields as `null`, while every other HTTP status throws.
|
||
/// 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]; per-track absence is the runtime gate.
|
||
Future<Lyrics?> fetchLyrics(MediaItem track);
|
||
|
||
/// Free-text search across the user's libraries. [limit] is a per-request
|
||
/// candidate budget; a backend may supplement omitted media categories and
|
||
/// return more candidates for cross-server ranking. [abort] cancels every
|
||
/// backend request owned by this search pass.
|
||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});
|
||
|
||
/// 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.
|
||
///
|
||
/// [cover] picks how the requested box is interpreted. The default sizes the
|
||
/// image to *cover* the box — every pixel of a poster/backdrop slot is
|
||
/// filled, at the cost of overshooting on the long axis. Pass `false` for
|
||
/// artwork drawn with [BoxFit.contain] (clear logos), where the overshoot is
|
||
/// decoded and thrown away: a 4313×1035 logo asked for at 1200×360 comes back
|
||
/// 1500×360 covering versus 1200×288 fitting, for ~20-30% more bytes and no
|
||
/// extra rendered detail. Neither mode crops or changes the aspect ratio.
|
||
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true});
|
||
|
||
/// 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.
|
||
///
|
||
/// [cover] carries the same meaning as in [thumbnailUrl].
|
||
String externalImageUrl(String url, {int? width, int? height, bool cover = true});
|
||
|
||
/// 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].
|
||
///
|
||
/// Neither backend can filter by external id — Plex's `guid=` matches only
|
||
/// the primary `plex://` guid (verified on PMS 1.43) and Jellyfin dropped
|
||
/// `anyProviderIdEquals` (silently ignored on 10.11.10) — so both search by
|
||
/// title and verify candidates against their exact external ids. Title
|
||
/// alone never produces a match.
|
||
///
|
||
/// [titles] are tried in order until one yields an id-verified candidate;
|
||
/// pass the entry's own title first and broader forms after (see
|
||
/// `titleMatchCandidates`). A sequel entry's own title never matches its
|
||
/// parent show, which is why more than one is needed. [year] applies a ±1
|
||
/// window to the first attempt only — for a sequel the catalog year is the
|
||
/// season's, not the show's.
|
||
///
|
||
/// [plexGuid] is a Plex-only escape hatch: a `plex://show/…` guid the caller
|
||
/// already holds, which the local server *can* filter on exactly, skipping
|
||
/// the title search. It is never resolved over the network — only Plex
|
||
/// Discover catalog items carry one, in their own rating key. Other backends
|
||
/// ignore it.
|
||
///
|
||
/// [season] gates the result: when the entry maps to season 2+ of a longer
|
||
/// series, a match is only returned if the server actually has that season.
|
||
/// Implementations MUST gate only on [ExternalSeasonRef.agreedSeason] — the
|
||
/// provider a library numbers its seasons by is a server-side setting no
|
||
/// dataset supplies, so a disagreeing ref is left ungated rather than gated
|
||
/// on a guess.
|
||
///
|
||
/// 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,
|
||
List<String> titles = const [],
|
||
int? year,
|
||
String? plexGuid,
|
||
ExternalSeasonRef? season,
|
||
});
|
||
|
||
/// 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, then fall back to cached data on an accepted error.
|
||
///
|
||
/// When [shouldFallback] is omitted, every error remains eligible for cache
|
||
/// fallback. A supplied selector narrows that policy; rejected errors are
|
||
/// rethrown unchanged before the fallback cache is read. Response-parser
|
||
/// failures can therefore be propagated instead of hidden by stale data.
|
||
///
|
||
/// 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 Function(Object error)? shouldFallback,
|
||
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);
|
||
final parsed = parseResponse(response);
|
||
if (cacheResponse) {
|
||
await _putCacheResponse(cacheScope, cacheKey, response.data);
|
||
}
|
||
return parsed;
|
||
} catch (e) {
|
||
if (shouldFallback != null && !shouldFallback(e)) rethrow;
|
||
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);
|
||
final parsed = parseResponse(response);
|
||
if (cacheResponse) {
|
||
await _putCacheResponse(cacheScope, cacheKey, response.data);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|