Files
plezy/lib/services/jellyfin_client.dart
T

2296 lines
90 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.
import 'dart:async';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import '../connection/connection.dart';
import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import 'favorite_channels_repository.dart';
import 'file_info_parser.dart';
import 'library_query_translator.dart';
import '../media/media_filter.dart';
import '../media/live_tv_support.dart';
import '../media/media_backend.dart';
import '../media/media_file_info.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../media/server_capabilities.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/external_ids.dart';
import '../utils/media_server_http_client.dart';
import '../utils/resolution_label.dart';
import '../utils/track_label_builder.dart';
import '../utils/watch_state_notifier.dart';
import '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../utils/jellyfin_time.dart';
import 'jellyfin_auth_header.dart';
import '../media/download_resolution.dart';
import 'api_cache.dart';
import 'download_artwork_helpers.dart';
import 'jellyfin_api_cache.dart';
import 'jellyfin_mappers.dart';
import 'jellyfin_media_info_mapper.dart';
import 'jellyfin_playback_bundle.dart';
import 'jellyfin_playback_urls.dart';
import 'jellyfin_trickplay_service.dart';
import 'playback_initialization_types.dart';
import 'scrub_preview_source.dart';
import '../mpv/mpv.dart';
part 'jellyfin_client/live_tv_support.dart';
/// [MediaServerClient] over a Jellyfin server.
///
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
/// HTTP wrapper is backend-agnostic despite the name). Implements the full
/// neutral interface: browse, watch state, playlist read, playback session
/// reporting, and live TV via [LiveTvSupport].
class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, ScopedMediaServerClient {
JellyfinClient._({
required JellyfinConnection connection,
required MediaServerHttpClient http,
FavoriteChannelsRepository? favoritesRepository,
}) : _connection = connection,
_http = http,
_favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
/// Build a fully-initialised [JellyfinClient]. The factory probes
/// `/System/Info/Public` to confirm the server is reachable; callers can
/// catch a [MediaServerHttpException] to surface a clean "unavailable" UI.
///
/// Sends the full `Authorization: MediaBrowser …, Token="…"` header on
/// every request — that's what the official Jellyfin SDK (and Findroid by
/// extension) does. Modern Jellyfin servers behind reverse proxies often
/// reject requests that only carry the legacy `X-Emby-Token` header,
/// returning 404 from the proxy or a routing-level handler instead of
/// 401. We send `X-Emby-Token` too for old Emby/Jellyfin builds.
static Future<JellyfinClient> create(
JellyfinConnection connection, {
FavoriteChannelsRepository? favoritesRepository,
}) async {
// Register before any HTTP traffic so the very first probe URL doesn't
// leak the token verbatim. `LogRedactionManager.redact()` also has
// pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the
// `Authorization: MediaBrowser ... Token="..."` header.
LogRedactionManager.registerServer(connection.baseUrl, connection.accessToken);
String version = '1.0';
try {
final pkg = await PackageInfo.fromPlatform();
if (pkg.version.isNotEmpty) version = pkg.version;
} catch (_) {
// Tests / non-platform contexts — keep the fallback version.
}
final authHeader = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: version,
deviceName: 'Plezy',
deviceId: connection.deviceId,
accessToken: connection.accessToken,
);
final headers = {
'Authorization': authHeader,
'X-Emby-Token': connection.accessToken,
'Accept': 'application/json',
// Jellyfin's session reporting endpoints (`/Sessions/Playing*`) reject
// any content-type carrying a `; charset=utf-8` suffix with 415 —
// pin to the SDK's exact wire format up-front.
'Content-Type': 'application/json',
};
final http = MediaServerHttpClient(baseUrl: connection.baseUrl, defaultHeaders: headers);
final client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository);
return client;
}
/// Test-only factory that injects an [http.Client] so URL-builder tests
/// can capture the request URI without spinning up a real Jellyfin server.
@visibleForTesting
static JellyfinClient forTesting({
required JellyfinConnection connection,
required http.Client httpClient,
FavoriteChannelsRepository? favoritesRepository,
}) {
final mediaHttp = MediaServerHttpClient(
baseUrl: connection.baseUrl,
defaultHeaders: {'X-Emby-Token': connection.accessToken, 'Accept': 'application/json'},
client: httpClient,
);
return JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository);
}
/// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the
/// `/Users/Me` probe response — admin status changed server-side should
/// propagate without forcing the user to re-auth.
JellyfinConnection _connection;
JellyfinConnection get connection => _connection;
final MediaServerHttpClient _http;
final FavoriteChannelsRepository _favoritesRepository;
bool _offlineMode = false;
/// Fired when the live `connection` snapshot diverges from the cached one
/// (currently only on admin-status change). [MultiServerManager] uses this
/// to re-broadcast status so admin-gated UI rebuilds.
FutureOr<void> Function(JellyfinConnection connection)? onConnectionUpdated;
/// Per-collection cache for [fetchCollectionPage]. Jellyfin's API doesn't
/// paginate collection children, so the first call materialises the full
/// list and subsequent paged calls slice from the same in-memory copy.
/// Lifetime is the client's lifetime — collections rarely change in a
/// single session, and a stale-but-bounded list is acceptable.
final Map<String, List<MediaItem>> _collectionItemsCache = {};
/// Read-only view of the headers attached to every outgoing request.
/// Test-only entry point for asserting the SDK-style `MediaBrowser`
/// Authorization shape — Findroid (and the official SDK) sends the same
/// thing.
@visibleForTesting
Map<String, String> get defaultHeadersForTesting => Map.unmodifiable(_http.defaultHeaders);
/// Image-path absolutizer scoped to this client's [connection]. Shared with
/// [JellyfinApiCache] (which constructs its own from the connection row's
/// `configJson`) so cache reads carry the same absolute URLs as live API
/// reads — see [JellyfinImageAbsolutizer].
JellyfinImageAbsolutizer get _absolutizer =>
JellyfinImageAbsolutizer(baseUrl: connection.baseUrl, accessToken: connection.accessToken);
String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path);
MediaItem? _mapItem(Map<String, dynamic> json) =>
JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer);
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items) =>
items.map(_mapItem).whereType<MediaItem>().toList();
@override
String get serverId => connection.serverMachineId;
@override
String get scopedServerId => connection.id;
@override
String? get serverName => connection.serverName;
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
/// Jellyfin doesn't expose a per-server played-threshold pref, so we mirror
/// Plex's default of 90%.
@override
double get watchedThreshold => 0.9;
@override
void close() => _http.close();
/// Reachable *and* token-valid. We probe `/Users/Me` (auth-required)
/// rather than `/System/Info/Public` so a revoked token surfaces as
/// unhealthy on the very next sweep, instead of waiting for the first
/// real call to 401.
///
/// Side-effect: when the response body carries a fresh
/// `Policy.IsAdministrator` that differs from the cached one, refresh the
/// connection so admin-gated UI catches the server-side change without
/// requiring re-auth (see [onConnectionUpdated]).
///
/// 401/403 surfaces as [HealthStatus.authError] so the manager can
/// distinguish a revoked token from a generic transport failure.
@override
Future<HealthStatus> checkHealth() async {
try {
final response = await _http.get('/Users/Me').timeout(const Duration(seconds: 8));
final ok = response.statusCode >= 200 && response.statusCode < 300;
if (ok) {
final data = response.data;
if (data is Map<String, dynamic>) {
final policy = data['Policy'];
if (policy is Map<String, dynamic>) {
final fresh = policy['IsAdministrator'] as bool?;
if (fresh != null && fresh != _connection.isAdministrator) {
_connection = _connection.copyWith(isAdministrator: fresh);
final listener = onConnectionUpdated;
if (listener != null) {
try {
await Future.sync(() => listener(_connection));
} catch (e, st) {
appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st);
}
}
}
}
}
return HealthStatus.online;
}
if (response.statusCode == 401 || response.statusCode == 403) {
return HealthStatus.authError;
}
return HealthStatus.offline;
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
return HealthStatus.offline;
} catch (_) {
return HealthStatus.offline;
}
}
@override
Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;
/// Fetch the authenticated user's `Configuration` (audio/subtitle language
/// prefs, auto-select flag) so the player can apply per-user defaults.
/// Returns null on transport failures — caller treats as "no preference".
Future<JellyfinUserProfile?> fetchUserProfile() async {
try {
final response = await _http.get('/Users/Me');
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) return null;
return JellyfinUserProfile.fromUserDto(data);
} catch (e, st) {
appLogger.w('JellyfinClient.fetchUserProfile failed', error: e, stackTrace: st);
return null;
}
}
@override
Future<String?> getMachineIdentifier() async {
try {
final response = await _http.get('/System/Info/Public');
throwIfHttpError(response);
final data = response.data;
if (data is Map<String, dynamic>) {
return data['Id'] as String?;
}
return connection.serverMachineId;
} catch (e) {
appLogger.w('JellyfinClient: getMachineIdentifier failed: $e');
return connection.serverMachineId;
}
}
@override
bool get isOfflineMode => _offlineMode;
@override
void setOfflineMode(bool offline) {
_offlineMode = offline;
}
/// Expose the Jellyfin cache through the [MediaServerClient] interface so
/// the shared `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers
/// route through the correct backend's cache substrate.
@override
ApiCache get cache => JellyfinApiCache.instance;
// ── Browse: libraries ────────────────────────────────────────────
//
// Endpoint conventions follow what the official Jellyfin Kotlin SDK
// generates (cross-checked against the Findroid client). The SDK mixes
// `/Users/{userId}/...` for "user library" / "views" / "latest" / "single
// item" calls and `/Items?userId=...` for the generic list and resume
// endpoints. We mirror that exactly so requests hash the same way against
// proxy rules and rate limiters as a stock Jellyfin app.
@override
Future<List<MediaLibrary>> fetchLibraries() async {
final response = await _http.get('/Users/${_segment(connection.userId)}/Views');
throwIfHttpError(response);
final items = _itemsArray(response.data);
// Jellyfin surfaces the user's collection (BoxSet) and playlist roots as
// top-level views. We expose those as per-library tabs instead of
// standalone library entries — matches the Plex shape and avoids
// duplicating the same data in two navigation slots.
return items
.where((view) {
final ct = (view['CollectionType'] as String?)?.toLowerCase();
return ct != 'boxsets' && ct != 'playlists';
})
.map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName))
.whereType<MediaLibrary>()
.toList();
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryContent(
String libraryId,
LibraryQuery query, {
AbortController? abort,
}) async {
final translator = JellyfinLibraryQueryTranslator(
userId: connection.userId,
parentId: libraryId,
fields: _browseFields,
);
final params = translator.toQueryParameters(query);
final response = await _http.get('/Items', queryParameters: params, abort: abort);
throwIfHttpError(response);
final data = response.data;
final items = _itemsArray(data);
final total = (data is Map<String, dynamic> ? data['TotalRecordCount'] as int? : null) ?? items.length;
return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset);
}
/// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags /
/// Categories + values from `/Items/Filters` in a single call. Keys are
/// translated to Plex's filter naming so the existing filter-param map
/// round-trips through `_buildFilterParams` unchanged; the synthesised
/// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can
/// recognise it as cached and skip the per-category value fetch.
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
final response = await _http.get(
'/Items/Filters',
queryParameters: {'userId': connection.userId, 'ParentId': libraryId},
);
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) return LibraryFilterResult.empty;
List<String> stringList(Object? raw) {
if (raw is! List) return const [];
return raw.whereType<String>().where((s) => s.isNotEmpty).toList();
}
final raw = <String, List<String>>{
'genre': stringList(data['Genres']),
'contentRating': stringList(data['OfficialRatings']),
'tag': stringList(data['Tags']),
'year': (data['Years'] is List)
? (data['Years'] as List).whereType<num>().map((y) => y.toInt().toString()).toList()
: const <String>[],
};
const order = ['genre', 'year', 'contentRating', 'tag'];
final titles = {
'genre': t.libraries.filterCategories.genre,
'year': t.libraries.filterCategories.year,
'contentRating': t.libraries.filterCategories.contentRating,
'tag': t.libraries.filterCategories.tag,
};
final filters = <MediaFilter>[];
final values = <String, List<MediaFilterValue>>{};
for (final key in order) {
final entries = raw[key];
if (entries == null || entries.isEmpty) continue;
filters.add(
MediaFilter(filter: key, filterType: 'string', key: 'jellyfin:$key', title: titles[key] ?? key, type: 'filter'),
);
final sorted = List<String>.from(entries);
if (key == 'year') {
sorted.sort((a, b) => (int.tryParse(b) ?? 0).compareTo(int.tryParse(a) ?? 0));
} else {
sorted.sort();
}
values[key] = sorted.map((v) => MediaFilterValue(key: v, title: v)).toList();
}
return LibraryFilterResult(filters: filters, cachedValues: values);
}
/// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded
/// list mirroring the Plex fallback set. Keys are the backend-neutral names
/// understood by [JellyfinLibraryQueryTranslator] (`title`, `addedAt`, …);
/// `_buildFilterParams` emits them as `addedAt:desc` etc., and
/// [LibraryQueryTranslator.parseSortParam] turns them back into a
/// [LibrarySort] before the translator maps them to Jellyfin's
/// `SortBy`/`SortOrder`.
@override
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType}) async {
return [
MediaSort(key: 'title', descKey: 'title:desc', title: t.libraries.sortLabels.title, defaultDirection: 'asc'),
MediaSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: t.libraries.sortLabels.dateAdded,
defaultDirection: 'desc',
),
MediaSort(
key: 'originallyAvailableAt',
descKey: 'originallyAvailableAt:desc',
title: t.libraries.sortLabels.releaseDate,
defaultDirection: 'desc',
),
MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'),
MediaSort(
key: 'lastViewedAt',
descKey: 'lastViewedAt:desc',
title: t.libraries.sortLabels.lastPlayed,
defaultDirection: 'desc',
),
MediaSort(
key: 'viewCount',
descKey: 'viewCount:desc',
title: t.libraries.sortLabels.playCount,
defaultDirection: 'desc',
),
MediaSort(key: 'random', title: t.libraries.sortLabels.random, defaultDirection: 'asc'),
];
}
/// Jellyfin internalisation of the Plex-style filter map → [LibraryQuery]
/// translation that previously lived in [DataAggregationService]. Routes
/// through the existing [fetchLibraryContent] so the
/// [JellyfinLibraryQueryTranslator] handles the actual `/Items` query.
///
/// [libraryKind] threads through so a "Shows" library returns Series rows
/// rather than the recursive episode expansion Jellyfin would otherwise
/// produce.
@override
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
String libraryId, {
required LibraryQuery query,
MediaKind? libraryKind,
AbortController? abort,
}) async {
// [libraryKind] takes priority over any kind already on [query] — the
// browse tab passes the library's actual kind (Series, Movie) to override
// a less specific value.
final effective = (libraryKind != null && libraryKind != MediaKind.unknown)
? query.copyWith(kind: libraryKind)
: query;
return fetchLibraryContent(libraryId, effective, abort: abort);
}
/// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters
/// at the item level (`raw['Chapters']`) and native skip segments through a
/// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort
/// so older servers still use chapter title fallback.
@override
Future<PlaybackExtras> fetchPlaybackExtras(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
bool forceRefresh = false,
}) async {
final item = await fetchItem(itemId);
final markers = item == null ? const <MediaMarker>[] : await _fetchMediaSegmentMarkers(itemId);
return jellyfinPlaybackExtrasFromRaw(
item?.raw,
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
markers: markers,
);
}
@override
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
}) async {
final item = await cache.getMetadata(cacheServerId, itemId);
if (item == null) return null;
final markers = await _fetchCachedMediaSegmentMarkers(itemId);
return jellyfinPlaybackExtrasFromRaw(
item.raw,
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
markers: markers,
);
}
@override
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
final item = await cache.getMetadata(cacheServerId, itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
final sources = raw['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final first = sources.first;
if (first is! Map<String, dynamic>) return null;
return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']);
}
@override
Future<ScrubPreviewSource?> createScrubPreviewSource({
required MediaItem item,
required MediaSourceInfo mediaSource,
}) async {
if (!capabilities.scrubThumbnails) return null;
final manifest = mediaSource.trickplayByWidth;
if (manifest == null || manifest.isEmpty) return null;
return JellyfinTrickplayService.create(
client: this,
itemId: item.id,
mediaSourceId: mediaSource.mediaSourceId,
manifest: manifest,
);
}
Future<List<MediaMarker>> _fetchMediaSegmentMarkers(String itemId) async {
final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId);
try {
return await fetchWithCacheFallback<List<MediaMarker>>(
cacheKey: endpoint,
networkCall: () async {
final response = await _http.get(endpoint);
if (response.statusCode == 404) {
return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri);
}
throwIfHttpError(response);
return response;
},
parseCache: jellyfinMediaSegmentsToMarkers,
parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data),
) ??
const [];
} on MediaServerHttpException catch (e) {
if (e.statusCode != 404) {
appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
}
return const [];
} catch (e) {
appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
return const [];
}
}
Future<List<MediaMarker>> _fetchCachedMediaSegmentMarkers(String itemId) async {
try {
final data = await cache.get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId));
return jellyfinMediaSegmentsToMarkers(data);
} catch (e) {
appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e);
return const [];
}
}
static String _segment(String value) => Uri.encodeComponent(value);
String _withApiKey(String urlOrPath) {
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath);
final params = Map<String, String>.from(uri.queryParameters)..['api_key'] = connection.accessToken;
return uri.replace(queryParameters: params).toString();
}
/// Jellyfin playback URL resolution.
///
/// Two paths:
/// * `qualityPreset.isOriginal` → direct stream
/// (`/Videos/{id}/stream?Static=true&api_key=...`).
/// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the
/// preset's bitrate and use the server-computed `TranscodingUrl`
/// from the returned `MediaSources` entry. Falls back to direct stream
/// when the server didn't provide a transcode URL (e.g. direct play
/// fits the cap) or the negotiation request failed.
///
/// The returned `MediaSourceInfo` is what the player uses for track-picker
/// labels and auto-track selection by language.
///
/// Throws [PlaybackException] when the item is missing or has no
/// `MediaSources`.
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
final metadata = options.metadata;
final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex);
if (bundle == null) {
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
}
var mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
bundle.selectedSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo);
// Only forward MediaSourceId when there's actually more than one source —
// single-source items have `MediaSourceId == itemId` so the param is a
// no-op there but adds clutter to logs.
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id
? bundle.selectedSourceId
: null;
String? videoUrl;
String? playSessionId;
var playMethod = 'DirectPlay';
var isTranscoding = false;
TranscodeFallbackReason? fallbackReason;
final preset = options.qualityPreset;
if (!preset.isOriginal && preset.videoBitrateKbps != null) {
final maxBps = preset.videoBitrateKbps! * 1000;
final negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxBps,
mediaSourceId: bundle.selectedSourceId,
audioStreamIndex: options.selectedAudioStreamId,
);
if (negotiation == null) {
fallbackReason = TranscodeFallbackReason.decisionFailed;
} else {
final sources = negotiation['MediaSources'];
Map<String, dynamic>? chosenSource;
if (sources is List && sources.isNotEmpty) {
for (final src in sources) {
if (src is Map<String, dynamic> && src['Id'] == bundle.selectedSourceId) {
chosenSource = src;
break;
}
}
chosenSource ??= sources.first is Map<String, dynamic> ? sources.first as Map<String, dynamic> : null;
}
final chosenStreams = chosenSource?['MediaStreams'];
if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) {
mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
chosenSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
externalSubtitles = _buildExternalSubtitles(
metadata.id,
chosenSource['Id'] as String? ?? bundle.selectedSourceId,
mediaInfo,
);
}
final transcodingUrl = chosenSource?['TranscodingUrl'];
if (transcodingUrl is String && transcodingUrl.isNotEmpty) {
// TranscodingUrl is server-relative and already encodes container,
// codecs, MediaSourceId, and PlaySessionId; we just append the
// api_key for auth.
playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
} else {
final directStreamUrl = chosenSource?['DirectStreamUrl'];
if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
} else {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
}
}
videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId);
return PlaybackInitializationResult(
availableVersions: bundle.availableVersions,
videoUrl: videoUrl,
mediaInfo: mediaInfo,
externalSubtitles: externalSubtitles,
isOffline: false,
isTranscoding: isTranscoding,
fallbackReason: fallbackReason,
activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null,
playSessionId: playSessionId,
playMethod: playMethod,
);
}
String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) {
final sourceId = mediaSourceId;
final streamIndex = track.index ?? track.id;
final codec = track.codec;
if (sourceId == null || codec == null || codec.isEmpty) return null;
final path = Uri(
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
).path;
return path.startsWith('/') ? path : '/$path';
}
List<SubtitleTrack> _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternal) continue;
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
// absolute URL with the api_key query param.
final url = _withApiKey(path);
externalSubtitles.add(
SubtitleTrack.uri(
url,
title:
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
cleanTrackMetadataValue(track.language),
language: cleanTrackMetadataValue(track.languageCode),
),
);
}
return externalSubtitles;
}
/// Internal accessor for [PlaybackInitializationService]. Returns the
/// chosen `MediaSource` JSON, every available source's [MediaVersion],
/// and the item's `Chapters` array. One round-trip vs. fetchItem + raw
/// extraction at the call site.
///
/// Returns `null` when the item doesn't exist or has no `MediaSources`.
/// [sourceIndex] is clamped to the valid range — out-of-bounds requests
/// fall back to source 0 to mirror Plex's `parseVideoPlaybackDataFromJson`.
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0}) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
final sources = raw['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final availableVersions = jellyfinSourcesToVersions(sources);
var index = sourceIndex;
if (index < 0 || index >= sources.length) index = 0;
final source = sources[index];
if (source is! Map<String, dynamic>) return null;
final chapters = raw['Chapters'];
return JellyfinPlaybackBundle(
availableVersions: availableVersions,
selectedSource: source,
chapters: chapters is List ? chapters : const [],
container: source['Container'] as String?,
selectedSourceId: source['Id'] as String?,
trickplay: raw['Trickplay'],
);
}
/// Synthesised 27-letter alphabet — Jellyfin has no equivalent of Plex's
/// `/firstCharacter` endpoint, so the UI treats the bar as a name-prefix
/// filter instead of a scroll affordance. Each entry has `size: 1` so
/// the alpha-jump helper renders it without trying to do offset math.
@override
Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters}) async {
const letters = [
'#',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
return [for (final l in letters) LibraryFirstCharacter(key: l, title: l, size: 1)];
}
/// Queue a metadata refresh for the library. Jellyfin treats a library
/// view as an item, so we POST to `/Items/{id}/Refresh`. `FullRefresh`
/// re-pulls metadata from configured providers; `replaceAllMetadata=false`
/// preserves user edits — same UX as Plex's `refresh?force=1`.
@override
Future<void> refreshLibraryMetadata(String libraryId) async {
final response = await _http.post(
'/Items/${_segment(libraryId)}/Refresh',
queryParameters: {
'metadataRefreshMode': 'FullRefresh',
'imageRefreshMode': 'Default',
'replaceAllMetadata': 'false',
'replaceAllImages': 'false',
},
);
throwIfHttpError(response);
}
/// Jellyfin has no single-round-trip equivalent of Plex's
/// `?includeOnDeck=1`. We approximate it for shows by chaining a second
/// request to `/Shows/NextUp` filtered by `seriesId`. NextUp's defaults
/// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex
/// OnDeck semantics: returns the resume episode when one exists, or S1E1
/// when the user hasn't started. Movies and other kinds short-circuit.
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
final item = await fetchItem(id);
if (item == null || item.kind != MediaKind.show) {
return (item: item, onDeckEpisode: null);
}
final nextUp = await _safeFetchItemsArray('/Shows/NextUp', {
'seriesId': id,
'userId': connection.userId,
'Limit': '1',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
});
final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first);
return (item: item, onDeckEpisode: onDeckEpisode);
}
@override
Future<MediaItem?> fetchItem(String id) async {
final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}';
// Contract:
// - 200 with parseable Map → MediaItem
// - 200 with non-Map body (HTML/text proxy page, empty) → null
// - 404 → null (item doesn't exist server-side)
// - 401/403/5xx → throw [MediaServerHttpException] so the UI can
// surface "auth required" / "server unavailable". Falling back to
// a cached row here would mislead the user into thinking they're
// still connected — explicit cache reads belong to the offline path.
// - Pure transport errors (no HTTP response) → fall back to cached row
// when present, otherwise rethrow.
if (_offlineMode) {
final cached = await cache.get(cacheServerId, endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached);
return null;
}
try {
final response = await _http.get(endpoint, queryParameters: {'Fields': _detailFields});
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) return null;
try {
await cache.put(cacheServerId, endpoint, data);
} catch (e, st) {
appLogger.w('JellyfinClient.fetchItem cache write failed', error: e, stackTrace: st);
}
return _mapItem(data);
} on MediaServerHttpException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
} catch (e) {
// Transport-layer failure: socket error, DNS, TLS, etc. Try cache.
appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
try {
final cached = await cache.get(cacheServerId, endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached);
} catch (cacheError, st) {
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st);
}
rethrow;
}
}
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
// Cache keys include userId so two users on the same server don't share
// per-user UserData (watched state) baked into the response.
final seasonsKey = '/Shows/$parentId/Seasons?userId=${connection.userId}';
final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}';
if (_offlineMode) {
final cachedSeasons = await cache.get(cacheServerId, seasonsKey);
if (cachedSeasons != null) {
final items = _itemsArray(cachedSeasons);
if (items.isNotEmpty) return _mapItems(items);
}
final cachedChildren = await cache.get(cacheServerId, childrenKey);
if (cachedChildren != null) {
return _mapItems(_itemsArray(cachedChildren));
}
return const [];
}
// For a series, the direct children are SEASONS (not the recursive
// episode expansion). Match Findroid: showsApi.getSeasons(seriesId)
// → /Shows/{seriesId}/Seasons. If the parent isn't a series this
// returns an empty list (or 404), so we fall through.
try {
final seasons = await _http.get(
'/Shows/${_segment(parentId)}/Seasons',
queryParameters: {'userId': connection.userId, 'Fields': _browseFields, ...jellyfinImageQueryParameters},
);
if (seasons.statusCode == 200) {
final data = seasons.data;
final items = _itemsArray(data);
if (items.isNotEmpty && data is Map<String, dynamic>) {
await cache.put(cacheServerId, seasonsKey, data);
return _mapItems(items);
}
}
} on MediaServerHttpException {
// Not a series — fall through to the generic ParentId query.
}
// Generic direct-children query: works for season → episodes,
// collection → items, etc.
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': parentId,
'Fields': _browseFields,
'Limit': '500',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
if (data is Map<String, dynamic>) {
await cache.put(cacheServerId, childrenKey, data);
}
return _mapItems(_itemsArray(data));
}
/// All directly-playable descendants of [parentId] (Movies + Episodes),
/// recursively expanded. Used by the playback launcher so a collection
/// containing a Series plays its episodes instead of the unplayable
/// Series entry, and a playlist mixing both comes through the same path.
/// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
/// since those preserve the container shape (Series rows, PlaylistItemId).
///
/// No `Limit` — Jellyfin returns the entire list for this endpoint by
/// default, same precedent as [fetchClientSideEpisodeQueue].
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': parentId,
'Recursive': 'true',
'IncludeItemTypes': 'Movie,Episode',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
/// All episodes of a series in air order, optimised for queue-building.
/// Uses [_queueFields] (only `UserData`) instead of the browse field
/// set so the response stays small even for shows with thousands of
/// episodes.
///
/// Paged in [_episodeQueuePageSize] chunks so long-running shows still get
/// a complete client-side next/previous queue without one huge response.
@override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
final all = <MediaItem>[];
var startIndex = 0;
int? totalRecordCount;
while (totalRecordCount == null || startIndex < totalRecordCount) {
final response = await _http.get(
'/Shows/${_segment(seriesId)}/Episodes',
queryParameters: {
'userId': connection.userId,
'Fields': _queueFields,
'StartIndex': '$startIndex',
'Limit': '$_episodeQueuePageSize',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
final page = _mapItems(_itemsArray(data));
all.addAll(page);
if (data is Map<String, dynamic>) {
final rawTotal = data['TotalRecordCount'];
if (rawTotal is int) totalRecordCount = rawTotal;
}
if (page.length < _episodeQueuePageSize) break;
startIndex += page.length;
}
return all;
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 30}) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'SearchTerm': query,
'Recursive': 'true',
'Limit': limit.toString(),
'IncludeItemTypes': 'Movie,Series,Episode',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
@override
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
// Matches userLibraryApi.getLatestMedia in the Jellyfin SDK.
final response = await _http.get(
'/Users/${_segment(connection.userId)}/Items/Latest',
queryParameters: {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
// Latest returns a bare array, not an Items wrapper.
if (data is List) {
return _mapItems(data.whereType<Map<String, dynamic>>());
}
return _mapItems(_itemsArray(data));
}
@override
Future<List<MediaItem>> fetchContinueWatching({int count = 20}) async {
final results = await Future.wait([
_fetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': count.toString(),
'Fields': _browseFields,
'MediaTypes': 'Video',
'Recursive': 'true',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': count.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}),
]);
return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count);
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10}) async {
// Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the
// home rows from three separate calls. The richer Plex Discover surface
// is intentionally left untranslated — see ServerCapabilities.richHubs.
final results = await Future.wait([
_safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': limit.toString(),
'Fields': _browseFields,
'MediaTypes': 'Video',
'Recursive': 'true',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': limit.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}),
]);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.continue',
title: t.discover.continueWatching,
type: 'mixed',
items: results[1],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.nextup',
title: t.discover.nextUp,
type: 'episode',
items: results[2],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.recent',
title: t.discover.recentlyAdded,
type: 'mixed',
items: results[0],
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
@override
Future<List<MediaHub>> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10}) async {
// Mirror the Jellyfin web client's per-library "Suggestions" tab:
// Continue Watching + Next Up (TV libraries) + Recently Added.
//
// Issued in parallel so the recommended tab loads in one round-trip.
// We probe the library kind first to decide whether to ask for NextUp
// — querying it for a movie library is harmless (returns []), but
// skipping the request keeps the wire chatter tighter.
final results = await Future.wait([
_safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': libraryId,
'Fields': _browseFields,
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'ParentId': libraryId,
'Limit': limit.toString(),
'Fields': _browseFields,
'MediaTypes': 'Video',
'Recursive': 'true',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'ParentId': libraryId,
'Limit': limit.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}),
]);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.continue',
title: t.discover.continueWatchingIn(library: libraryName),
type: 'mixed',
items: results[1],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.nextup',
title: t.discover.nextUpIn(library: libraryName),
type: 'episode',
items: results[2],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.recent',
title: t.discover.recentlyAddedIn(library: libraryName),
type: 'mixed',
items: results[0],
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
/// Re-run the synthetic hub query without the preview limit so the
/// hub-detail screen can render the full list. Branches on the
/// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]:
/// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume,
/// `*.nextup` → NextUp. Unknown ids return an empty list.
@override
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
final effectiveLimit = (limit ?? 50).toString();
String? parentId;
if (hubId.startsWith('library.')) {
final rest = hubId.substring('library.'.length);
final dot = rest.lastIndexOf('.');
if (dot > 0) parentId = rest.substring(0, dot);
}
final tail = hubId.split('.').last;
final List<Map<String, dynamic>> items;
switch (tail) {
case 'recent':
items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': effectiveLimit,
'Fields': _browseFields,
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
});
break;
case 'continue':
items = await _safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': effectiveLimit,
'Fields': _browseFields,
'Recursive': 'true',
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
...jellyfinImageQueryParameters,
});
break;
case 'nextup':
items = await _safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': effectiveLimit,
'Fields': _browseFields,
'ParentId': ?parentId,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
});
break;
default:
return const [];
}
return _mapItems(items);
}
@override
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
final response = await _http.get(
'/Items/${_segment(id)}/Similar',
queryParameters: {
'userId': connection.userId,
'Limit': count.toString(),
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'item.$id.similar',
title: 'More Like This',
type: 'mixed',
items: _itemsArray(response.data),
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
// ── Watch state ──────────────────────────────────────────────────
@override
Future<void> markWatched(MediaItem item) async {
final response = await _http.post(
'/UserPlayedItems/${_segment(item.id)}',
queryParameters: {'userId': connection.userId},
);
throwIfHttpError(response);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
}
@override
Future<void> markUnwatched(MediaItem item) async {
final response = await _http.delete(
'/UserPlayedItems/${_segment(item.id)}',
queryParameters: {'userId': connection.userId},
);
throwIfHttpError(response);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: false, cacheServerId: cacheServerId);
}
@override
Future<void> removeFromContinueWatching(MediaItem item) async {
// Jellyfin uses a `Hide` endpoint to remove items from Continue Watching.
final response = await _http.post(
'/UserItems/${_segment(item.id)}/HideFromResume',
queryParameters: {'userId': connection.userId, 'Hide': 'true'},
);
throwIfHttpError(response);
}
@override
Future<void> rate(MediaItem item, double rating) async {
// Lossy mapping — Jellyfin only stores a binary like/dislike. Treat
// a negative input as "clear the rating" (DELETE), >= 6/10 as a like
// (POST Likes=true), and the rest as a dislike (POST Likes=false).
final response = rating < 0
? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId})
: await _http.post(
'/UserItems/${_segment(item.id)}/Rating',
queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()},
);
throwIfHttpError(response);
}
// ── Playlist read ────────────────────────────────────────────────
@override
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'IncludeItemTypes': 'Playlist',
'Recursive': 'true',
'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final requestedType = playlistType.toLowerCase();
return _itemsArray(response.data).map(_playlistFromJson).where((playlist) {
if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false;
if (smart != null && playlist.smart != smart) return false;
return true;
}).toList();
}
@override
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
final item = await fetchItem(id);
if (item == null) return null;
return MediaPlaylist(
id: item.id,
backend: MediaBackend.jellyfin,
title: item.title ?? 'Playlist',
summary: item.summary,
smart: false,
playlistType: _playlistMediaType(item),
durationMs: item.durationMs,
leafCount: item.leafCount,
thumbPath: item.thumbPath,
addedAt: item.addedAt,
updatedAt: item.updatedAt,
serverId: serverId,
serverName: serverName,
);
}
@override
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
final response = await _http.get(
'/Playlists/${_segment(id)}/Items',
queryParameters: {
'userId': connection.userId,
'StartIndex': offset.toString(),
'Limit': limit.toString(),
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
// ── Playlist write ───────────────────────────────────────────────
@override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
final response = await _http.post(
'/Playlists',
queryParameters: {
'Name': title,
'Ids': items.map((i) => i.id).join(','),
'UserId': connection.userId,
'MediaType': 'Video',
},
);
throwIfHttpError(response);
final data = response.data;
final newId = data is Map<String, dynamic> ? data['Id'] as String? : null;
if (newId == null || newId.isEmpty) return null;
return fetchPlaylistMetadata(newId);
}
@override
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final response = await _http.post(
'/Playlists/${_segment(playlistId)}/Items',
queryParameters: {'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
// Jellyfin treats playlists as items — same delete endpoint.
final response = await _http.delete('/Items/${_segment(playlist.id)}');
throwIfHttpError(response);
return true;
}
/// Jellyfin's move endpoint takes an absolute index, so [afterItem] is
/// ignored — its sibling Plex impl needs it for `?after=`. The "wrong
/// backend" / "missing playlistItemId" branches still return `false`
/// (business not-applicable, not a network error) so callers can revert
/// optimistic UI changes; an HTTP error throws like the rest of the
/// write surface.
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
if (item is! JellyfinMediaItem) {
appLogger.e('movePlaylistItem: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})');
return false;
}
if (item.playlistItemId == null) {
appLogger.e('movePlaylistItem: item ${item.id} ("${item.title}") has no playlistItemId');
return false;
}
final response = await _http.post(
'/Playlists/${_segment(playlistId)}/Items/${_segment(item.playlistItemId!)}/Move/$newIndex',
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) async {
if (item is! JellyfinMediaItem) {
appLogger.e('removeFromPlaylist: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})');
return false;
}
if (item.playlistItemId == null) {
appLogger.e('removeFromPlaylist: item ${item.id} ("${item.title}") has no playlistItemId');
return false;
}
final response = await _http.delete(
'/Playlists/${_segment(playlistId)}/Items',
queryParameters: {'entryIds': item.playlistItemId},
);
throwIfHttpError(response);
return true;
}
// ── Collections ──────────────────────────────────────────────────
@override
Future<List<MediaItem>> fetchCollections(String libraryId) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': libraryId,
'IncludeItemTypes': 'BoxSet',
'Recursive': 'true',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
/// Jellyfin has no pagination knob for collection children, so the first
/// call materialises the full list via [fetchChildren] and subsequent
/// paged calls slice from the same in-memory copy ([_collectionItemsCache]).
/// The [abort] hook is unused on this backend — the slice path is
/// synchronous and the underlying fetch is short-lived.
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
}) async {
final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId);
final s = start ?? 0;
final fullSize = cached.length;
final from = s.clamp(0, fullSize);
final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize);
return LibraryPage<MediaItem>(items: cached.sublist(from, to), totalCount: fullSize, offset: s);
}
Future<List<MediaItem>> _loadAndCacheCollectionItems(String collectionId) async {
final items = await fetchChildren(collectionId);
_collectionItemsCache[collectionId] = items;
return items;
}
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
// ParentId is optional on Jellyfin's `/Collections` endpoint — when
// omitted the server picks a default BoxSet root. We pass libraryId so
// the new collection lives in the same library as the seeded items.
final response = await _http.post(
'/Collections',
queryParameters: {
'Name': title,
if (items.isNotEmpty) 'Ids': items.map((i) => i.id).join(','),
if (libraryId.isNotEmpty) 'ParentId': libraryId,
},
);
throwIfHttpError(response);
final data = response.data;
return data is Map<String, dynamic> ? data['Id'] as String? : null;
}
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final response = await _http.post(
'/Collections/${_segment(collectionId)}/Items',
queryParameters: {'Ids': items.map((i) => i.id).join(',')},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
final response = await _http.delete(
'/Collections/${_segment(collectionId)}/Items',
queryParameters: {'Ids': item.id},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> deleteCollection(MediaItem collection) async {
final response = await _http.delete('/Items/${_segment(collection.id)}');
throwIfHttpError(response);
return true;
}
// ── Item write ───────────────────────────────────────────────────
@override
Future<bool> deleteMediaItem(MediaItem item) async {
final response = await _http.delete('/Items/${_segment(item.id)}');
throwIfHttpError(response);
return true;
}
// ── File info ────────────────────────────────────────────────────
@override
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
// Browse responses already include `MediaSources` (see [_browseFields]).
// Re-fetch via [fetchItem] only if the inline data isn't available.
final raw = item.raw is Map<String, dynamic> ? item.raw as Map<String, dynamic> : null;
Map<String, dynamic>? itemJson = raw;
if (itemJson == null || itemJson['MediaSources'] is! List) {
final fresh = await fetchItem(item.id);
itemJson = fresh?.raw is Map<String, dynamic> ? fresh!.raw as Map<String, dynamic> : null;
}
if (itemJson == null) return null;
return _buildFileInfoFromJellyfinItem(itemJson);
}
MediaFileInfo? _buildFileInfoFromJellyfinItem(Map<String, dynamic> json) {
final sources = json['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final source = sources.first;
if (source is! Map<String, dynamic>) return null;
final parsed = walkStreams(source['MediaStreams'] as List?, const JellyfinFileInfoStreamReader());
final videoStream = parsed.videoStream;
final audioStream = parsed.audioStream;
final audioTracks = parsed.audioTracks;
final subtitleTracks = parsed.subtitleTracks;
final width = videoStream?['Width'] as int?;
final height = videoStream?['Height'] as int?;
final aspectRatioString = videoStream?['AspectRatio'] as String?;
double? aspectRatio;
if (aspectRatioString != null && aspectRatioString.contains(':')) {
final parts = aspectRatioString.split(':');
final num = double.tryParse(parts[0]);
final den = double.tryParse(parts[1]);
if (num != null && den != null && den != 0) aspectRatio = num / den;
}
aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null;
final runtimeTicks = source['RunTimeTicks'] as int?;
final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10000) : null;
final bitrateBps = source['Bitrate'] as int?;
final videoBitrateBps = videoStream?['BitRate'] as int?;
return MediaFileInfo(
container: source['Container'] as String?,
videoCodec: videoStream?['Codec'] as String?,
videoResolution: resolutionLabelFromDimensions(width, height),
videoFrameRate: videoStream?['RealFrameRate']?.toString() ?? videoStream?['AverageFrameRate']?.toString(),
videoProfile: videoStream?['Profile'] as String?,
width: width,
height: height,
aspectRatio: aspectRatio,
// Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps.
bitrate: bitrateBps != null ? bitrateBps ~/ 1000 : null,
duration: durationMs,
audioCodec: audioStream?['Codec'] as String?,
audioProfile: audioStream?['Profile'] as String?,
audioChannels: audioStream?['Channels'] as int?,
filePath: source['Path'] as String?,
fileSize: source['Size'] as int?,
colorSpace: videoStream?['ColorSpace'] as String?,
colorRange: videoStream?['ColorRange'] as String?,
colorPrimaries: videoStream?['ColorPrimaries'] as String?,
chromaSubsampling: null,
frameRate:
(videoStream?['RealFrameRate'] as num?)?.toDouble() ?? (videoStream?['AverageFrameRate'] as num?)?.toDouble(),
bitDepth: videoStream?['BitDepth'] as int?,
videoBitrate: videoBitrateBps != null ? videoBitrateBps ~/ 1000 : null,
audioChannelLayout: audioStream?['ChannelLayout'] as String?,
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
);
}
// ── Playback (stream URL building + session reporting) ──────────
/// Direct-stream URL for [itemId]. Best for files the device can play
/// natively. Adds `?Static=true` to skip the transcoder and
/// `&api_key=...` so the request authenticates without a header.
///
/// Pass [mediaSourceId] to stream a non-default alternate version. When the
/// item only has a single MediaSource, [mediaSourceId] equals [itemId] and
/// can be omitted; for items with multiple versions Jellyfin uses the
/// param to pick which file to serve.
String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
container: container,
mediaSourceId: mediaSourceId,
);
}
/// Trickplay sprite-sheet URL. [width] picks one of the resolutions
/// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based
/// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails).
/// Pass [mediaSourceId] when the item has more than one source so the
/// server returns the matching version's tiles.
String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) {
return buildJellyfinTrickplayTileUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
width: width,
sheetIndex: sheetIndex,
mediaSourceId: mediaSourceId,
);
}
/// Negotiate playback: returns the parsed `MediaSources[]` array and the
/// server's recommended `PlaySessionId`. Caller decides which media source
/// to use and feeds the returned `TranscodingUrl` into the player.
///
/// [maxStreamingBitrate] is forwarded as both the top-level field and inside
/// the `DeviceProfile` so the server caps direct-stream and transcode bitrate
/// against the same ceiling. [mediaSourceId] pins the negotiation to a
/// specific version when the item has multiple sources. [audioStreamIndex]
/// / [subtitleStreamIndex] tell the server which streams to pick for the
/// transcode profile (Jellyfin's negotiation factors them in when picking
/// codec compatibility).
Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, {
int maxStreamingBitrate = 100000000,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
try {
final query = <String, String>{
'userId': connection.userId,
'MaxStreamingBitrate': maxStreamingBitrate.toString(),
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex?.toString(),
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
};
final response = await _http.post(
'/Items/${_segment(itemId)}/PlaybackInfo',
queryParameters: query,
body: {
'UserId': connection.userId,
'MaxStreamingBitrate': maxStreamingBitrate,
'DeviceProfile': <String, Object?>{
'Name': 'Plezy',
'MaxStreamingBitrate': maxStreamingBitrate,
'CodecProfiles': const <Map<String, Object?>>[],
// Comma-separated codec lists are order-sensitive — first entry
// wins when the server picks an output codec. HEVC is listed
// ahead of H.264 so a server that has "Allow encoding in HEVC
// format" enabled will actually emit HEVC instead of falling
// back to H.264.
'TranscodingProfiles': const <Map<String, Object?>>[
{
'Type': 'Video',
'Container': 'ts',
'Protocol': 'hls',
'VideoCodec': 'hevc,h264',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus',
},
],
// Declaring HEVC in DirectPlayProfile.VideoCodec stops the server
// from forcing a transcode for HEVC sources whose container we
// already accept — mpv decodes HEVC natively on every platform
// we ship.
'DirectPlayProfiles': const <Map<String, Object?>>[
{
'Type': 'Video',
'Container': 'mp4,mkv,m4v,webm,mov,ts',
'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts',
},
],
'SubtitleProfiles': const <Map<String, Object?>>[
{'Format': 'srt', 'Method': 'External'},
{'Format': 'ass', 'Method': 'External'},
{'Format': 'ssa', 'Method': 'External'},
{'Format': 'vtt', 'Method': 'External'},
{'Format': 'pgssub', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'External'},
{'Format': 'dvbsub', 'Method': 'External'},
],
},
},
);
throwIfHttpError(response);
final data = response.data;
return data is Map<String, dynamic> ? data : null;
} catch (e, st) {
appLogger.w('JellyfinClient: getPlaybackInfo failed', error: e, stackTrace: st);
return null;
}
}
@override
Future<ExternalIds> fetchExternalIds(String itemId) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
final providerIds = raw is Map<String, dynamic> ? raw['ProviderIds'] : null;
if (providerIds is Map<String, dynamic>) {
return ExternalIds.fromJellyfinProviderIds(providerIds);
}
return const ExternalIds();
}
/// Jellyfin embeds the access token in the URL query string (`api_key=...`)
/// rather than relying on headers, so the player needs no extra headers
/// for direct streams.
@override
Map<String, String> get streamHeaders => const {};
/// Tell the server the user has started playing [itemId]. Body shape
/// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the
/// same fields, and Jellyfin's session tracker drops events that omit
/// `PlayMethod` because it has no way to associate progress with an
/// active session row.
///
/// [duration] is accepted for interface symmetry with Plex but ignored —
/// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes
/// are still sent so the active session reflects the chosen tracks.
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
final response = await _http.post(
'/Sessions/Playing',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'CanSeek': true,
'IsPaused': false,
'IsMuted': false,
'PlayMethod': playMethod ?? 'DirectPlay',
'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId,
},
);
throwIfHttpError(response);
}
/// Periodic progress ping (510s cadence is typical). Server uses this to
/// drive the resume position, detect idle sessions, and save remembered
/// audio/subtitle stream indexes when enabled in Jellyfin user settings.
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
final response = await _http.post(
'/Sessions/Playing/Progress',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'CanSeek': true,
'IsPaused': isPaused,
'IsMuted': false,
'PlayMethod': playMethod ?? 'DirectPlay',
'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId,
},
);
throwIfHttpError(response);
}
/// End-of-playback signal. Final position becomes the resume bookmark.
/// [duration] is accepted for interface symmetry with Plex but ignored.
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
}) async {
final response = await _http.post(
'/Sessions/Playing/Stopped',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'Failed': false,
'PlaySessionId': ?playSessionId,
},
);
throwIfHttpError(response);
}
// ── Live TV ──────────────────────────────────────────────────────
/// Returns `true` when this server has Live TV configured (channels
/// available). Probes `/LiveTv/Channels?limit=1`. Used by [MultiServerProvider]
/// to gate the Live TV menu.
Future<bool> hasLiveTv() async {
try {
final response = await _http.get(
'/LiveTv/Channels',
queryParameters: {'limit': '1', 'userId': connection.userId},
);
if (response.statusCode != 200) return false;
final data = response.data;
if (data is Map<String, dynamic>) {
final total = data['TotalRecordCount'];
if (total is int) return total > 0;
final items = data['Items'];
if (items is List) return items.isNotEmpty;
}
return false;
} catch (e) {
appLogger.d('Jellyfin Live TV probe failed', error: e);
return false;
}
}
/// Fetch the user's Live TV channel list. Each `BaseItemDto` of type
/// `TvChannel` is mapped to a [LiveTvChannel].
Future<List<LiveTvChannel>> fetchLiveTvChannels() async {
final items = await _safeFetchItemsArray('/LiveTv/Channels', {
'userId': connection.userId,
'enableImages': 'true',
'enableUserData': 'true',
'sortBy': 'SortName',
'sortOrder': 'Ascending',
});
return items.map(_channelFromJson).toList();
}
/// EPG / programs grid. [channelIds] scopes to specific channels (when
/// empty, the server returns programs across all channels). [beginsAt] /
/// [endsAt] are epoch seconds and bound the time window — Jellyfin uses
/// ISO 8601 strings on the wire.
Future<List<LiveTvProgram>> fetchLiveTvPrograms({
List<String> channelIds = const [],
int? beginsAt,
int? endsAt,
}) async {
DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true);
final params = <String, dynamic>{
'userId': connection.userId,
'enableImages': 'true',
'sortBy': 'StartDate',
'sortOrder': 'Ascending',
if (channelIds.isNotEmpty) 'channelIds': channelIds.join(','),
if (beginsAt != null) 'minStartDate': toDt(beginsAt)!.toIso8601String(),
if (endsAt != null) 'maxStartDate': toDt(endsAt)!.toIso8601String(),
};
final items = await _safeFetchItemsArray('/LiveTv/Programs', params);
return items.map(_programFromJson).toList();
}
LiveTvProgram _programFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String?;
int? toEpochSec(dynamic raw) {
if (raw is! String || raw.isEmpty) return null;
final ms = DateTime.tryParse(raw)?.toUtc().millisecondsSinceEpoch;
return ms != null ? ms ~/ 1000 : null;
}
final tags = json['ImageTags'];
String? primaryTag;
if (tags is Map<String, dynamic>) {
primaryTag = tags['Primary'] as String?;
}
final thumbPath = (id != null && primaryTag != null)
? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}')
: null;
return LiveTvProgram(
key: id,
ratingKey: id,
guid: null,
title: json['Name'] as String? ?? 'Unknown Program',
summary: json['Overview'] as String?,
type: 'episode',
year: (json['ProductionYear'] as num?)?.toInt(),
beginsAt: toEpochSec(json['StartDate']),
endsAt: toEpochSec(json['EndDate']),
grandparentTitle: json['SeriesName'] as String?,
parentTitle: json['SeasonName'] as String?,
index: (json['IndexNumber'] as num?)?.toInt(),
parentIndex: (json['ParentIndexNumber'] as num?)?.toInt(),
thumb: thumbPath,
art: null,
channelIdentifier: json['ChannelId'] as String?,
channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?,
live: json['IsLive'] as bool?,
premiere: json['IsPremiere'] as bool?,
);
}
LiveTvChannel _channelFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String? ?? '';
final name = json['Name'] as String?;
final number = json['Number'] as String? ?? json['ChannelNumber'] as String?;
final tags = json['ImageTags'];
String? primaryTag;
if (tags is Map<String, dynamic>) {
primaryTag = tags['Primary'] as String?;
}
final thumbPath = primaryTag != null
? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}')
: null;
return LiveTvChannel(
key: id,
identifier: id,
callSign: json['CallSign'] as String?,
title: name,
thumb: thumbPath,
art: null,
number: number,
hd: false,
lineup: null,
slug: null,
drm: null,
serverId: serverId,
serverName: serverName,
);
}
// ── Images ───────────────────────────────────────────────────────
@override
String thumbnailUrl(String? path, {int? width, int? height}) {
if (path == null || path.isEmpty) return '';
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path);
final params = Map<String, String>.from(uri.queryParameters);
if (width != null && !params.containsKey('maxWidth') && !params.containsKey('MaxWidth')) {
params['maxWidth'] = '$width';
}
if (height != null && !params.containsKey('maxHeight') && !params.containsKey('MaxHeight')) {
params['maxHeight'] = '$height';
}
params.putIfAbsent('api_key', () => connection.accessToken);
return uri.replace(queryParameters: params).toString();
}
/// Jellyfin doesn't expose an external-URL proxy endpoint comparable to
/// Plex's `/photo/:/transcode?url=...`. External URLs pass through.
@override
String externalImageUrl(String url, {int? width, int? height}) => url;
/// Toggle the per-user `IsFavorite` flag for [itemId]. Used by the live-TV
/// favorite-channel adapter; works on any Jellyfin item.
Future<void> _setItemFavorite(String itemId, bool isFavorite) async {
final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}';
final response = isFavorite ? await _http.post(path) : await _http.delete(path);
throwIfHttpError(response);
}
// ── Private helpers ──────────────────────────────────────────────
List<MediaItem> _mergeContinueWatchingAndNextUp({
required List<MediaItem> resume,
required List<MediaItem> nextUp,
required int limit,
}) {
if (limit <= 0) return const [];
final result = <MediaItem>[];
final seenIds = <String>{};
final seenSeriesIds = <String>{};
void add(MediaItem item) {
if (!seenIds.add(item.id)) return;
final seriesId = item.kind == MediaKind.episode ? item.grandparentId : null;
if (seriesId != null && !seenSeriesIds.add(seriesId)) return;
result.add(item);
}
for (final item in resume) {
add(item);
if (result.length >= limit) return result;
}
for (final item in nextUp) {
add(item);
if (result.length >= limit) return result;
}
return result;
}
Future<List<Map<String, dynamic>>> _fetchItemsArray(String path, Map<String, dynamic> queryParameters) async {
final response = await _http.get(path, queryParameters: queryParameters);
throwIfHttpError(response);
return _itemsArray(response.data);
}
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(String path, Map<String, dynamic> queryParameters) async {
try {
final response = await _http.get(path, queryParameters: queryParameters);
throwIfHttpError(response);
final data = response.data;
if (data is List) {
return data.whereType<Map<String, dynamic>>().toList();
}
return _itemsArray(data);
} catch (e, st) {
appLogger.w('JellyfinClient: $path failed (treating as empty)', error: e, stackTrace: st);
return const [];
}
}
static List<Map<String, dynamic>> _itemsArray(Object? data) {
if (data is Map<String, dynamic>) {
final items = data['Items'];
if (items is List) return items.whereType<Map<String, dynamic>>().toList();
}
if (data is List) return data.whereType<Map<String, dynamic>>().toList();
return const [];
}
/// Slim field set for grid/list browsing — what the card UI actually
/// renders (title, year, watched badge, episode count for series),
/// plus `MediaSources` so the long-press "Play Version" gate matches
/// Plex's flow (Plex always inlines `Media[]`).
///
/// The real Jellyfin web client + Findroid skip explicit `Fields` for
/// list calls; we ask for the minimum extras needed to drive the
/// MediaItem mapper:
/// - `RecursiveItemCount`/`ChildCount` for series leaf count
/// - `UserData` is included in defaults but pinned for safety
/// - `PremiereDate` for sort-by-release-date and episode metadata
/// - `OriginalTitle`/`SortName` for sort + alphabetised display
/// - `Overview` so episode-list rows show their description
/// - `MediaSources` so the context menu can hide `Play Version` when
/// there's nothing to pick (cost: ~40ms per 50-item page)
///
/// Heavier fields (`People`, `Genres`, `Tags`, `Studios`, `Taglines`,
/// `ProviderIds`, `Chapters`) stay in [_detailFields] — together they
/// added ~6s to a 100-item Series page on a small home server.
static const _browseFields =
'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview,MediaSources';
/// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode
/// index, and watched state. Title + indices come back without any
/// `Fields` request; we only need to ask for `UserData` for the
/// watched indicator. Drops `Overview` etc. so that even a thousand-
/// episode shounen show fits comfortably in one response.
static const _queueFields = 'UserData';
/// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response
/// bounded while still returning the full series queue.
static const _episodeQueuePageSize = 200;
/// Full field set for the detail screen and the resume / next-up
/// pre-fetch paths. Mirrors what the Jellyfin web detail view requests.
static const _detailFields =
'Overview,Genres,People,Studios,ProductionLocations,Tags,Taglines,DateCreated,DateLastSaved,'
'PremiereDate,RecursiveItemCount,ChildCount,UserData,MediaSources,OriginalTitle,SortName,'
// Chapters: Jellyfin returns them at the item level; the playback
// init flow plucks `raw['Chapters']` and feeds the seek-bar tick UI.
'Chapters,'
// Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail
// loader reads `raw['Trickplay']` and computes tile URLs from it.
'Trickplay,'
// ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the
// unified tracker coordinator to scrobble Jellyfin items without
// any extra round-trip.
'ProviderIds';
MediaPlaylist _playlistFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String? ?? '';
return MediaPlaylist(
id: id,
backend: MediaBackend.jellyfin,
title: json['Name'] as String? ?? 'Playlist',
summary: json['Overview'] as String?,
smart: false,
playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video',
leafCount: json['ChildCount'] as int?,
addedAt: _epochSecondsFromJson(json['DateCreated'] as String?),
updatedAt: _epochSecondsFromJson(json['DateLastSaved'] as String?),
thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])),
serverId: serverId,
serverName: serverName,
);
}
String _playlistMediaType(MediaItem item) {
if (item.kind == MediaKind.track || item.kind == MediaKind.album) return 'audio';
if (item.kind == MediaKind.photo) return 'photo';
return 'video';
}
static int? _epochSecondsFromJson(String? iso) {
if (iso == null || iso.isEmpty) return null;
final dt = DateTime.tryParse(iso);
return dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000;
}
static String? _imageTagPath(String id, Object? tags) {
if (tags is! Map<String, dynamic>) return null;
final tag = tags['Primary'];
if (tag is! String) return null;
return '/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(tag)}';
}
@override
LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this);
// ── Downloads ────────────────────────────────────────────────────
@override
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
if (bundle == null) return buildDirectStreamUrl(item.id);
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id
? bundle.selectedSourceId
: null;
return buildDirectStreamUrl(item.id, container: bundle.container, mediaSourceId: pinnedSourceId);
}
@override
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
final selectedSourceId = bundle?.selectedSourceId;
final pinnedSourceId = selectedSourceId != null && selectedSourceId != item.id ? selectedSourceId : null;
// Direct-stream the selected original file. Jellyfin's `Static=true`
// skips the transcoder so the byte-for-byte source lands on disk.
final videoUrl = buildDirectStreamUrl(item.id, container: bundle?.container, mediaSourceId: pinnedSourceId);
// External subtitle sidecars are listed in the per-source MediaStreams.
// PlaybackInfo gives us the canonical view including DeliveryUrl when
// the server has pre-computed one; fall back to the documented stream
// URL pattern otherwise.
final subtitles = <DownloadSubtitleSpec>[];
final pbInfo = await getPlaybackInfo(item.id);
if (pbInfo != null) {
final sources = pbInfo['MediaSources'];
if (sources is List && sources.length > mediaIndex) {
final source = sources[mediaIndex];
if (source is Map<String, dynamic>) {
final mediaSourceId = (source['Id'] as String?) ?? item.id;
final streams = source['MediaStreams'];
if (streams is List) {
for (final raw in streams) {
if (raw is! Map<String, dynamic>) continue;
if (raw['Type'] != 'Subtitle') continue;
final fields = parseJellyfinStreamFields(raw);
if (!fields.isExternal) continue;
final index = raw['Index'];
if (index is! int) continue;
final codec = fields.codec?.toLowerCase();
final delivery = fields.deliveryUrl;
final url = _withApiKey(
delivery != null && delivery.isNotEmpty
? delivery
: '/Videos/${_segment(item.id)}/${_segment(mediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}',
);
subtitles.add(
DownloadSubtitleSpec(
id: index,
url: url,
codec: codec,
language: fields.language,
languageCode: fields.languageCode,
forced: fields.isForced,
displayTitle: fields.displayTitle,
),
);
}
}
}
}
}
return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles);
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
// Jellyfin paths flow through `_absolutizeImagePath` at the mapper
// boundary, so artwork fields on the [MediaItem] are already absolute
// URLs. buildArtworkSpecs strips auth query params from localKey so the
// storage layer never hashes or persists access tokens.
return buildArtworkSpecs(item, (path) => path);
}
}