From 0383a77b560bd5007bf0d3beafe392b9e19bcb23 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 19 May 2026 20:12:19 +0200 Subject: [PATCH] fix(jellyfin): pin version playback source --- lib/media/media_server_client.dart | 5 +- lib/screens/video_player/parts/build.dart | 1 + .../video_player/parts/playback_start.dart | 1 + lib/screens/video_player_screen.dart | 3 + lib/services/external_player_service.dart | 7 ++- .../parts/images_downloads.dart | 6 +- .../jellyfin_client/parts/playback.dart | 18 ++++-- lib/services/jellyfin_mappers.dart | 2 +- lib/services/jellyfin_media_info_mapper.dart | 2 +- .../playback_initialization_service.dart | 2 + .../playback_initialization_types.dart | 5 ++ lib/services/plex_client.dart | 2 +- lib/utils/resolution_label.dart | 13 ++-- lib/utils/video_player_navigation.dart | 6 ++ lib/widgets/media_context_menu.dart | 3 +- .../video_controls/parts/navigation.dart | 4 ++ .../video_controls/video_controls.dart | 4 ++ test/services/jellyfin_client_urls_test.dart | 60 +++++++++++++++++++ test/services/jellyfin_media_info_test.dart | 25 ++++++++ .../jellyfin_playback_bundle_test.dart | 31 ++++++++++ 20 files changed, 181 insertions(+), 19 deletions(-) diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index b269f039..186d03e6 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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 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 resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}); + Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}); } /// Optional interface for backends whose public server id is not specific diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 26599617..1d93069a 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -210,6 +210,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onPrevious: onPrevious, availableVersions: _availableVersions, selectedMediaIndex: widget.selectedMediaIndex, + selectedMediaSourceId: widget.selectedMediaSourceId, selectedQualityPreset: _selectedQualityPreset, serverSupportsTranscoding: _serverSupportsTranscoding, isTranscoding: _isTranscoding, diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 3bbd9783..e46adf02 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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) { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index bdf55c40..6273db75 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 with WidgetsBindin _playbackDataFuture = playbackService.getPlaybackData( metadata: _currentMetadata, selectedMediaIndex: widget.selectedMediaIndex, + selectedMediaSourceId: widget.selectedMediaSourceId, preferOffline: _selectedQualityPreset.isOriginal, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 32567c53..a3dda570 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -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); diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index a1438dee..874d6bf5 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -2,7 +2,7 @@ part of '../../jellyfin_client.dart'; mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { JellyfinConnection get connection; - Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0}); + Future 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 resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async { - final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); + Future 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 diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index e84fa7e1..55f4dc17 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -135,7 +135,11 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { @override Future 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 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 fetchPlaybackBundle(String itemId, {int sourceIndex = 0, String? sourceId}) async { final item = await fetchItem(itemId); final raw = item?.raw; if (raw is! Map) 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 && source['Id'] == requestedSourceId); + if (byId >= 0) index = byId; + } if (index < 0 || index >= sources.length) index = 0; final source = sources[index]; if (source is! Map) return null; diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 9b0c5255..dba52913 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -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?, diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 084b5bba..4795169d 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -339,7 +339,7 @@ List jellyfinSourcesToVersions(List 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, diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index b9bf2f59..fdb92daa 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -110,6 +110,7 @@ class PlaybackInitializationService { Future 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, diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index dffb3873..c4ed8a33 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -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, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7a91ae70..89e86318 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3813,7 +3813,7 @@ class PlexClient // ── Downloads ──────────────────────────────────────────────────── @override - Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async { + Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async { final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex); return playbackData.hasValidVideoUrl ? playbackData.videoUrl : null; } diff --git a/lib/utils/resolution_label.dart b/lib/utils/resolution_label.dart index c285a9d4..f510da62 100644 --- a/lib/utils/resolution_label.dart +++ b/lib/utils/resolution_label.dart @@ -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); +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 456e8fc3..90c3040e 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -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 navigateToVideoPlayer( SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, + String? selectedMediaSourceId, TranscodeQualityPreset? selectedQualityPreset, bool usePushReplacement = false, bool isOffline = false, @@ -93,6 +95,7 @@ Future navigateToVideoPlayer( metadata: metadata, client: mediaClient, mediaIndex: mediaIndex, + mediaSourceId: selectedMediaSourceId, ); } @@ -120,6 +123,7 @@ Future navigateToVideoPlayer( preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, ), @@ -153,6 +157,7 @@ Future navigateToVideoPlayerWithRefresh( SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, + String? selectedMediaSourceId, bool usePushReplacement = false, }) async { final result = await navigateToVideoPlayer( @@ -163,6 +168,7 @@ Future navigateToVideoPlayerWithRefresh( preferredSubtitleTrack: preferredSubtitleTrack, preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: selectedMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, usePushReplacement: usePushReplacement, ); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index fc4961bc..e41bc327 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -875,9 +875,9 @@ class MediaContextMenuState extends State { 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 { context, metadata: item, selectedMediaIndex: selectedVersionIndex, + selectedMediaSourceId: selectedVersion?.id, selectedQualityPreset: selectedQuality, ); return true; diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 355db5e9..7c304b23 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -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, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 5a899d46..a873c6e7 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -84,6 +84,7 @@ Widget plexVideoControlsBuilder( VoidCallback? onPrevious, List? 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 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, diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index b3d63aa5..1e6a296b 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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; + 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( diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index 0076b93e..bbffde8b 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -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'}, diff --git a/test/services/jellyfin_playback_bundle_test.dart b/test/services/jellyfin_playback_bundle_test.dart index 370f4977..a0a02b0c 100644 --- a/test/services/jellyfin_playback_bundle_test.dart +++ b/test/services/jellyfin_playback_bundle_test.dart @@ -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',