fix(jellyfin): pin version playback source

This commit is contained in:
edde746
2026-05-19 20:12:19 +02:00
parent d58007a9e2
commit 0383a77b56
20 changed files with 181 additions and 19 deletions
+2 -3
View File
@@ -522,8 +522,7 @@ abstract class MediaServerClient {
/// Resolve the download URL for [item]'s primary video file along with
/// any external subtitle tracks that should be saved alongside it.
///
/// [mediaIndex] selects among multiple media versions when an item has
/// them (Plex only — Jellyfin returns the same file regardless).
/// [mediaIndex] selects among multiple media versions when an item has them.
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0});
/// The artwork files the download pipeline should persist for [item] so
@@ -537,7 +536,7 @@ abstract class MediaServerClient {
/// media version's part path; Jellyfin returns its `/Videos/{id}/stream`
/// endpoint with `Static=true` so transcoding is bypassed. Returns null
/// when the backend can't resolve a playable URL for the item.
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0});
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId});
}
/// Optional interface for backends whose public server id is not specific
@@ -210,6 +210,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onPrevious: onPrevious,
availableVersions: _availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
selectedQualityPreset: _selectedQualityPreset,
serverSupportsTranscoding: _serverSupportsTranscoding,
isTranscoding: _isTranscoding,
@@ -142,6 +142,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
result = await offlineService.getPlaybackData(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
preferOffline: true,
);
if (result.videoUrl == null) {
+3
View File
@@ -152,6 +152,7 @@ class VideoPlayerScreen extends StatefulWidget {
final SubtitleTrack? preferredSubtitleTrack;
final SubtitleTrack? preferredSecondarySubtitleTrack;
final int selectedMediaIndex;
final String? selectedMediaSourceId;
final bool isOffline;
/// Quality preset override for this playback. When `null`, the screen uses
@@ -194,6 +195,7 @@ class VideoPlayerScreen extends StatefulWidget {
this.preferredSubtitleTrack,
this.preferredSecondarySubtitleTrack,
this.selectedMediaIndex = 0,
this.selectedMediaSourceId,
this.isOffline = false,
this.selectedQualityPreset,
this.selectedAudioStreamId,
@@ -601,6 +603,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_playbackDataFuture = playbackService.getPlaybackData(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
preferOffline: _selectedQualityPreset.isOriginal,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
+6 -1
View File
@@ -24,6 +24,7 @@ class ExternalPlayerService {
MediaItem? metadata,
MediaServerClient? client,
int mediaIndex = 0,
String? mediaSourceId,
String? videoUrl,
}) async {
try {
@@ -32,7 +33,11 @@ class ExternalPlayerService {
if (videoUrl != null) {
resolvedUrl = videoUrl;
} else if (client != null && metadata != null) {
final url = await client.resolveExternalPlaybackUrl(metadata, mediaIndex: mediaIndex);
final url = await client.resolveExternalPlaybackUrl(
metadata,
mediaIndex: mediaIndex,
mediaSourceId: mediaSourceId,
);
if (url == null || url.isEmpty) {
if (context.mounted) {
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
@@ -2,7 +2,7 @@ part of '../../jellyfin_client.dart';
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0});
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId});
String buildDirectStreamUrl(
String itemId, {
String? container,
@@ -48,8 +48,8 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
String externalImageUrl(String url, {int? width, int? height}) => url;
@override
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId);
if (bundle == null) return buildDirectStreamUrl(item.id);
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id
? bundle.selectedSourceId
@@ -135,7 +135,11 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
final metadata = options.metadata;
final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex);
final bundle = await fetchPlaybackBundle(
metadata.id,
sourceIndex: options.selectedMediaIndex,
sourceId: options.selectedMediaSourceId,
);
if (bundle == null) {
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
}
@@ -338,9 +342,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
/// 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 {
/// [sourceId] wins when present because Jellyfin plugins may reorder merged
/// `MediaSources` between requests. [sourceIndex] is clamped to the valid
/// range as a fallback to mirror Plex's `parseVideoPlaybackDataFromJson`.
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId}) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
@@ -348,6 +353,11 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
if (sources is! List || sources.isEmpty) return null;
final availableVersions = jellyfinSourcesToVersions(sources);
var index = sourceIndex;
final requestedSourceId = sourceId?.trim();
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
final byId = sources.indexWhere((source) => source is Map<String, dynamic> && source['Id'] == requestedSourceId);
if (byId >= 0) index = byId;
}
if (index < 0 || index >= sources.length) index = 0;
final source = sources[index];
if (source is! Map<String, dynamic>) return null;
+1 -1
View File
@@ -49,7 +49,7 @@ MediaVersion jellyfinMediaSourceToVersion(
id: versionId,
width: exposeDimensions ? width : null,
height: exposeDimensions ? height : null,
videoResolution: resolutionLabelFromHeight(height),
videoResolution: resolutionLabelFromDimensions(width, height),
videoCodec: hasParsedVideo ? parsedVideo.codec : rawVideo?['Codec'] as String?,
bitrate: bitrateKbpsFromBps(flexibleInt(source['Bitrate'])),
container: source['Container'] as String?,
+1 -1
View File
@@ -339,7 +339,7 @@ List<MediaVersion> jellyfinSourcesToVersions(List<dynamic> sources) {
versions.add(
jellyfinMediaSourceToVersion(
src,
versionId: i.toString(),
versionId: sourceId.isNotEmpty ? sourceId : i.toString(),
partId: i.toString(),
streamPath: sourceId,
name: useName ? src['Name'] as String? : null,
@@ -110,6 +110,7 @@ class PlaybackInitializationService {
Future<PlaybackInitializationResult> getPlaybackData({
required MediaItem metadata,
required int selectedMediaIndex,
String? selectedMediaSourceId,
bool preferOffline = false,
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
int? selectedAudioStreamId,
@@ -142,6 +143,7 @@ class PlaybackInitializationService {
PlaybackInitializationOptions(
metadata: metadata,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
qualityPreset: qualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier,
@@ -13,6 +13,10 @@ class PlaybackInitializationOptions {
/// Picks among multiple `MediaSources[]` versions when an item has them.
final int selectedMediaIndex;
/// Stable backend source id for the selected media version. Jellyfin merged
/// versions can reorder between item fetches, so this wins over index there.
final String? selectedMediaSourceId;
/// Transcode preset. `original` means direct-play; anything else asks the
/// server to transcode when supported.
final TranscodeQualityPreset qualityPreset;
@@ -31,6 +35,7 @@ class PlaybackInitializationOptions {
const PlaybackInitializationOptions({
required this.metadata,
required this.selectedMediaIndex,
this.selectedMediaSourceId,
this.qualityPreset = TranscodeQualityPreset.original,
this.selectedAudioStreamId,
this.sessionIdentifier,
+1 -1
View File
@@ -3813,7 +3813,7 @@ class PlexClient
// ── Downloads ────────────────────────────────────────────────────
@override
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex);
return playbackData.hasValidVideoUrl ? playbackData.videoUrl : null;
}
+9 -4
View File
@@ -14,7 +14,12 @@ String? resolutionLabelFromHeight(int? height) {
return height.toString();
}
/// Convenience overload that takes width + height. Width is ignored — the
/// label is height-driven — but the signature matches earlier per-backend
/// helpers so callers don't have to drop a parameter on the floor.
String? resolutionLabelFromDimensions(int? width, int? height) => resolutionLabelFromHeight(height);
/// Convenience overload that takes width + height. Width is considered first
/// for scope-cropped files, e.g. `3840x1608` should still be labeled `4k`.
String? resolutionLabelFromDimensions(int? width, int? height) {
if ((width != null && width >= 3840) || (height != null && height >= 2160)) return '4k';
if ((width != null && width >= 1920) || (height != null && height >= 1080)) return '1080';
if ((width != null && width >= 1280) || (height != null && height >= 720)) return '720';
if ((width != null && width >= 854) || (height != null && height >= 480)) return '480';
return resolutionLabelFromHeight(height);
}
+6
View File
@@ -38,6 +38,7 @@ class WatchTogetherPlaybackNavigationException implements Exception {
/// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start
/// - [selectedMediaIndex]: Optional media version index to use; if not provided,
/// loads the saved preference for the series/movie. Defaults to 0 if no preference exists.
/// - [selectedMediaSourceId]: Optional stable backend source id for the chosen version.
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
/// useful for episode-to-episode navigation. Defaults to false.
/// - [isOffline]: If true, plays from downloaded content without requiring server connection.
@@ -51,6 +52,7 @@ Future<bool?> navigateToVideoPlayer(
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
int? selectedMediaIndex,
String? selectedMediaSourceId,
TranscodeQualityPreset? selectedQualityPreset,
bool usePushReplacement = false,
bool isOffline = false,
@@ -93,6 +95,7 @@ Future<bool?> navigateToVideoPlayer(
metadata: metadata,
client: mediaClient,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
}
@@ -120,6 +123,7 @@ Future<bool?> navigateToVideoPlayer(
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
),
@@ -153,6 +157,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
SubtitleTrack? preferredSubtitleTrack,
SubtitleTrack? preferredSecondarySubtitleTrack,
int? selectedMediaIndex,
String? selectedMediaSourceId,
bool usePushReplacement = false,
}) async {
final result = await navigateToVideoPlayer(
@@ -163,6 +168,7 @@ Future<bool?> navigateToVideoPlayerWithRefresh(
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
usePushReplacement: usePushReplacement,
);
+2 -1
View File
@@ -875,9 +875,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
selectedVersionIndex = picked;
}
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
TranscodeQualityPreset selectedQuality = TranscodeQualityPreset.original;
if (canTranscode) {
final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null;
final picked = await showQualityPickerDialog(
context,
sourceBitrateKbps: selectedVersion?.bitrate,
@@ -892,6 +892,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
context,
metadata: item,
selectedMediaIndex: selectedVersionIndex,
selectedMediaSourceId: selectedVersion?.id,
selectedQualityPreset: selectedQuality,
);
return true;
@@ -150,6 +150,9 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
final effectiveMediaIndex = newMediaIndex ?? widget.selectedMediaIndex;
final effectivePreset = newPreset ?? widget.selectedQualityPreset;
final effectiveAudioStreamId = newAudioStreamId ?? widget.selectedAudioStreamId;
final effectiveMediaSourceId = effectiveMediaIndex >= 0 && effectiveMediaIndex < widget.availableVersions.length
? widget.availableVersions[effectiveMediaIndex].id
: widget.selectedMediaSourceId;
final isVersionChange = effectiveMediaIndex != widget.selectedMediaIndex;
final isPresetChange = effectivePreset != widget.selectedQualityPreset;
@@ -193,6 +196,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: widget.metadata.copyWith(viewOffsetMs: currentPosition.inMilliseconds),
selectedMediaIndex: effectiveMediaIndex,
selectedMediaSourceId: effectiveMediaSourceId,
selectedQualityPreset: effectivePreset,
selectedAudioStreamId: effectiveAudioStreamId,
reusedSessionIdentifier: sessionId,
@@ -84,6 +84,7 @@ Widget plexVideoControlsBuilder(
VoidCallback? onPrevious,
List<MediaVersion>? availableVersions,
int? selectedMediaIndex,
String? selectedMediaSourceId,
TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original,
bool serverSupportsTranscoding = false,
bool isTranscoding = false,
@@ -128,6 +129,7 @@ Widget plexVideoControlsBuilder(
onPrevious: onPrevious,
availableVersions: availableVersions ?? [],
selectedMediaIndex: selectedMediaIndex ?? 0,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
serverSupportsTranscoding: serverSupportsTranscoding,
isTranscoding: isTranscoding,
@@ -209,6 +211,7 @@ class PlexVideoControls extends StatefulWidget {
final VoidCallback? onPrevious;
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final String? selectedMediaSourceId;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final bool isTranscoding;
@@ -298,6 +301,7 @@ class PlexVideoControls extends StatefulWidget {
this.onPrevious,
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.selectedMediaSourceId,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.isTranscoding = false,
@@ -557,6 +557,66 @@ void main() {
expect(uri.queryParameters['MediaSourceId'], 'src-2');
});
test('playback initialization pins selected media source id over index', () async {
Uri? playbackInfoUri;
String? playbackInfoBody;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Items/item-1') {
return http.Response(
jsonEncode({
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{
'Id': 'src-4k',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
],
},
{
'Id': 'src-1080',
'Container': 'mp4',
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
],
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
playbackInfoUri = request.url;
playbackInfoBody = request.body;
return http.Response('server unavailable', 500);
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'),
selectedMediaIndex: 0,
selectedMediaSourceId: 'src-1080',
),
);
expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'src-1080');
final body = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
expect(body['MediaSourceId'], 'src-1080');
expect(result.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']);
final uri = Uri.parse(result.videoUrl!);
expect(uri.queryParameters['MediaSourceId'], 'src-1080');
expect(uri.queryParameters['Container'], 'mp4');
});
test('getPlaybackInfo path-encodes reserved item id characters', () async {
Uri? capturedUri;
final scoped = JellyfinClient.forTesting(
@@ -359,6 +359,7 @@ void main() {
},
]);
expect(versions, hasLength(1));
expect(versions.single.id, 'src-1');
expect(versions.single.parts.single.streamPath, 'src-1');
expect(versions.single.videoResolution, '4k');
expect(versions.single.videoCodec, 'hevc');
@@ -394,6 +395,8 @@ void main() {
expect(versions, hasLength(2));
expect(versions[0].name, 'Theatrical Cut');
expect(versions[1].name, "Director's Cut");
expect(versions[0].id, 'src-theatrical');
expect(versions[1].id, 'src-directors');
expect(versions[0].parts.single.streamPath, 'src-theatrical');
expect(versions[1].parts.single.streamPath, 'src-directors');
// displayLabel prefixes the name for disambiguation.
@@ -426,6 +429,28 @@ void main() {
expect(versions[1].videoResolution, '1080');
});
test('uses width for scope-cropped 4k and 1080p labels', () {
final versions = jellyfinSourcesToVersions([
{
'Id': 'scope-4k',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
],
},
{
'Id': 'scope-1080',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
],
},
]);
expect(versions[0].videoResolution, '4k');
expect(versions[1].videoResolution, '1080');
});
test('handles missing MediaStreams + missing Height gracefully', () {
final versions = jellyfinSourcesToVersions([
{'Id': 'x', 'Name': 'X', 'Container': 'mkv'},
@@ -137,6 +137,37 @@ void main() {
client.close();
});
test('selects sourceId before sourceIndex when both are provided', () async {
final body = jsonEncode({
'Id': 'item-4',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-4k',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
],
},
{
'Id': 'src-1080',
'Container': 'mp4',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
],
},
],
});
final client = buildClient(body);
final bundle = await client.fetchPlaybackBundle('item-4', sourceIndex: 0, sourceId: 'src-1080');
expect(bundle!.selectedSourceId, 'src-1080');
expect(bundle.container, 'mp4');
expect(bundle.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']);
client.close();
});
test('chapters defaults to empty list when item has no Chapters field', () async {
final body = jsonEncode({
'Id': 'item-3',