Files
plezy/lib/services/jellyfin_client/parts/playback.dart
T

634 lines
25 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.
part of '../../jellyfin_client.dart';
mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
/// 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 as JellyfinClient,
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 [];
}
}
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.
///
/// Always POSTs `/Items/{id}/PlaybackInfo` so Jellyfin can resolve external
/// audio/subtitle streams server-side. Uses the returned `TranscodingUrl` or
/// `DirectStreamUrl` when present, otherwise falls back to a static direct
/// stream URL (`/Videos/{id}/stream?Static=true&api_key=...`).
///
/// 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 effectiveSourceId = bundle.selectedSourceId;
var effectiveContainer = bundle.container;
var externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo);
String? videoUrl;
String? playSessionId;
var playMethod = 'DirectPlay';
var isTranscoding = false;
TranscodeFallbackReason? fallbackReason;
final preset = options.qualityPreset;
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100000) * 1000;
final negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxStreamingBitrate,
mediaSourceId: bundle.selectedSourceId,
audioStreamIndex: requestedAudioStreamId,
);
if (negotiation == null) {
if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.decisionFailed;
}
} else {
final chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
if (chosenSource != null) {
effectiveSourceId = chosenSource['Id'] as String? ?? effectiveSourceId;
effectiveContainer = chosenSource['Container'] as String? ?? effectiveContainer;
if (chosenSource['MediaStreams'] is List) {
mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
chosenSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo);
}
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
void capturePlaySessionId(String urlOrPath) {
playSessionId = Uri.tryParse(urlOrPath)?.queryParameters['PlaySessionId'];
if ((playSessionId == null || playSessionId!.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
}
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.
capturePlaySessionId(transcodingUrl);
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
} else {
final directStreamUrl = chosenSource['DirectStreamUrl'];
if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
capturePlaySessionId(directStreamUrl);
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
} else if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
} else if (!preset.isOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
// 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 = effectiveSourceId != null && effectiveSourceId != metadata.id ? effectiveSourceId : null;
videoUrl ??= buildDirectStreamUrl(
metadata.id,
container: effectiveContainer,
mediaSourceId: pinnedSourceId,
audioStreamIndex: requestedAudioStreamId,
);
return PlaybackInitializationResult(
availableVersions: bundle.availableVersions,
videoUrl: videoUrl,
mediaInfo: mediaInfo,
externalSubtitles: externalSubtitles,
isOffline: false,
isTranscoding: isTranscoding,
fallbackReason: fallbackReason,
activeAudioStreamId: requestedAudioStreamId,
playSessionId: playSessionId,
playMethod: playMethod,
);
}
int? _validJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
if (explicit == null) return null;
return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null;
}
Map<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
if (sources is! List || sources.isEmpty) return null;
for (final source in sources) {
if (source is Map<String, dynamic> && source['Id'] == selectedSourceId) {
return source;
}
}
final first = sources.first;
return first is Map<String, dynamic> ? first : null;
}
int? _resolveJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) {
final validExplicit = _validJellyfinAudioStreamId(explicit, mediaInfo);
if (validExplicit != null) return validExplicit;
final defaultStreamIndex = mediaInfo.defaultAudioStreamIndex;
if (defaultStreamIndex != null) return defaultStreamIndex;
for (final track in mediaInfo.audioTracks) {
if (track.selected) return track.id;
}
return null;
}
MediaSourceInfo _withSelectedJellyfinAudioStream(MediaSourceInfo mediaInfo, int? selectedStreamId) {
if (selectedStreamId == null || !mediaInfo.audioTracks.any((track) => track.id == selectedStreamId)) {
return mediaInfo;
}
return MediaSourceInfo(
videoUrl: mediaInfo.videoUrl,
audioTracks: [
for (final track in mediaInfo.audioTracks)
MediaAudioTrack(
id: track.id,
index: track.index,
codec: track.codec,
language: track.language,
languageCode: track.languageCode,
title: track.title,
displayTitle: track.displayTitle,
channels: track.channels,
selected: track.id == selectedStreamId,
external: track.external,
),
],
subtitleTracks: mediaInfo.subtitleTracks,
chapters: mediaInfo.chapters,
partId: mediaInfo.partId,
displayCriteria: mediaInfo.displayCriteria,
mediaSourceId: mediaInfo.mediaSourceId,
defaultAudioStreamIndex: mediaInfo.defaultAudioStreamIndex,
defaultSubtitleStreamIndex: mediaInfo.defaultSubtitleStreamIndex,
trickplayByWidth: mediaInfo.trickplayByWidth,
);
}
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'],
);
}
/// 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,
String? playSessionId,
String? liveStreamId,
int? audioStreamIndex,
}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
container: container,
mediaSourceId: mediaSourceId,
playSessionId: playSessionId,
liveStreamId: liveStreamId,
audioStreamIndex: audioStreamIndex,
);
}
/// 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.
///
/// When non-null, [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. Original playback passes null
/// to avoid capping high-bitrate files. [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,
String? liveStreamId,
int? audioStreamIndex,
int? subtitleStreamIndex,
bool? autoOpenLiveStream,
bool? enableDirectPlay,
bool? enableDirectStream,
bool? enableTranscoding,
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
}) async {
try {
final query = <String, String>{
'userId': connection.userId,
'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(),
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'AudioStreamIndex': ?audioStreamIndex?.toString(),
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(),
'EnableDirectPlay': ?enableDirectPlay?.toString(),
'EnableDirectStream': ?enableDirectStream?.toString(),
'EnableTranscoding': ?enableTranscoding?.toString(),
'AllowVideoStreamCopy': ?allowVideoStreamCopy?.toString(),
'AllowAudioStreamCopy': ?allowAudioStreamCopy?.toString(),
};
final response = await _http.post(
'/Items/${_segment(itemId)}/PlaybackInfo',
queryParameters: query,
body: {
'UserId': connection.userId,
'MaxStreamingBitrate': ?maxStreamingBitrate,
'MediaSourceId': ?mediaSourceId,
'LiveStreamId': ?liveStreamId,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'AutoOpenLiveStream': ?autoOpenLiveStream,
'EnableDirectPlay': ?enableDirectPlay,
'EnableDirectStream': ?enableDirectStream,
'EnableTranscoding': ?enableTranscoding,
'AllowVideoStreamCopy': ?allowVideoStreamCopy,
'AllowAudioStreamCopy': ?allowAudioStreamCopy,
'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,mpeg2video',
'AudioCodec': 'aac,mp3,mp2,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);
}
}