diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 874d6bf5..c27a96ff 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -51,32 +51,36 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { 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 - : null; - return buildDirectStreamUrl(item.id, container: bundle.container, mediaSourceId: pinnedSourceId); + return buildDirectStreamUrl( + item.id, + container: bundle.container, + mediaSourceId: bundle.pinnedSourceIdForItem(item.id), + ); } @override Future 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); + final videoUrl = buildDirectStreamUrl( + item.id, + container: bundle?.container, + mediaSourceId: bundle?.pinnedSourceIdForItem(item.id), + ); // 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 = []; - final pbInfo = await getPlaybackInfo(item.id); + final pbInfo = await getPlaybackInfo(item.id, mediaSourceId: selectedSourceId); if (pbInfo != null) { final sources = pbInfo['MediaSources']; - if (sources is List && sources.length > mediaIndex) { - final source = sources[mediaIndex]; - if (source is Map) { + if (sources is List && sources.isNotEmpty) { + final source = _selectDownloadMediaSource(sources, selectedSourceId, mediaIndex); + if (source != null) { final mediaSourceId = (source['Id'] as String?) ?? item.id; final streams = source['MediaStreams']; if (streams is List) { @@ -114,6 +118,21 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles); } + Map? _selectDownloadMediaSource(List sources, String? selectedSourceId, int mediaIndex) { + final requestedSourceId = selectedSourceId?.trim(); + if (requestedSourceId != null && requestedSourceId.isNotEmpty) { + for (final source in sources) { + if (source is Map && + (source['Id'] as String?)?.toLowerCase() == requestedSourceId.toLowerCase()) { + return source; + } + } + return null; + } + final source = mediaIndex >= 0 && mediaIndex < sources.length ? sources[mediaIndex] : sources.first; + return source is Map ? source : null; + } + @override List resolveDownloadArtwork(MediaItem item) { // Jellyfin paths flow through `_absolutizeImagePath` at the mapper diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 55f4dc17..38c3e79d 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -219,10 +219,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 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; + final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id); videoUrl ??= buildDirectStreamUrl( metadata.id, container: effectiveContainer, @@ -251,10 +248,15 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { Map? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) { if (sources is! List || sources.isEmpty) return null; - for (final source in sources) { - if (source is Map && source['Id'] == selectedSourceId) { - return source; + final requestedSourceId = selectedSourceId?.trim(); + if (requestedSourceId != null && requestedSourceId.isNotEmpty) { + for (final source in sources) { + if (source is Map && + (source['Id'] as String?)?.toLowerCase() == requestedSourceId.toLowerCase()) { + return source; + } } + return null; } final first = sources.first; return first is Map ? first : null; diff --git a/lib/services/jellyfin_playback_bundle.dart b/lib/services/jellyfin_playback_bundle.dart index eee28694..05a2f07e 100644 --- a/lib/services/jellyfin_playback_bundle.dart +++ b/lib/services/jellyfin_playback_bundle.dart @@ -25,9 +25,9 @@ class JellyfinPlaybackBundle { /// `buildDirectStreamUrl` so the player gets the right extension hint. final String? container; - /// `Id` of the selected source. Forwarded as `MediaSourceId=` only when - /// there's more than one source on the item; single-source items have - /// `Id == itemId` so the param adds noise without changing behaviour. + /// `Id` of the selected source. Multi-source items must forward this as + /// `MediaSourceId=` even when it equals the item id; otherwise Jellyfin + /// falls back to its first sorted source instead of the selected version. final String? selectedSourceId; /// Item-level `Trickplay` manifest (raw JSON object). `null` when the @@ -42,4 +42,13 @@ class JellyfinPlaybackBundle { this.selectedSourceId, this.trickplay, }); + + /// Source id to pin in playback/download URLs. Preserve the old single-source + /// behavior when Jellyfin's source id differs from the item id, and also pin + /// multi-source primary items where the selected source id equals [itemId]. + String? pinnedSourceIdForItem(String itemId) { + final id = selectedSourceId?.trim(); + if (id == null || id.isEmpty) return null; + return availableVersions.length > 1 || id != itemId ? id : null; + } } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 1e6a296b..7f6f0a6d 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -141,6 +141,7 @@ void main() { test('resolveDownload pins direct stream URL and subtitles to selected media source', () async { final requests = []; + String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { @@ -161,6 +162,7 @@ void main() { ); } if (request.url.path == '/Items/item-1/PlaybackInfo') { + playbackInfoBody = request.body; return http.Response( jsonEncode({ 'MediaSources': [ @@ -200,6 +202,10 @@ void main() { expect(uri.queryParameters['MediaSourceId'], 'src-2'); expect(uri.queryParameters['Container'], 'mkv'); expect(requests.map((u) => u.path), contains('/Items/item-1/PlaybackInfo')); + final playbackInfoRequest = requests.firstWhere((u) => u.path == '/Items/item-1/PlaybackInfo'); + expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-2'); + final body = jsonDecode(playbackInfoBody!) as Map; + expect(body['MediaSourceId'], 'src-2'); expect(resolution.externalSubtitles, hasLength(1)); final subtitle = resolution.externalSubtitles.single; expect(subtitle.id, 3); @@ -210,6 +216,41 @@ void main() { expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); + test('resolveExternalPlaybackUrl pins primary source id when alternates exist', () async { + 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': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final url = await scoped.resolveExternalPlaybackUrl( + MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + mediaIndex: 0, + mediaSourceId: 'item-1', + ); + + final uri = Uri.parse(url!); + expect(uri.queryParameters['MediaSourceId'], 'item-1'); + expect(uri.queryParameters['Container'], 'mp4'); + }); + test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), @@ -617,6 +658,134 @@ void main() { expect(uri.queryParameters['Container'], 'mp4'); }); + test('playback initialization pins primary source id for multi-source direct fallback', () 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': 'item-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }), + 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: 'item-1', + ), + ); + + expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'item-1'); + final body = jsonDecode(playbackInfoBody!) as Map; + expect(body['MediaSourceId'], 'item-1'); + final uri = Uri.parse(result.videoUrl!); + expect(uri.queryParameters['MediaSourceId'], 'item-1'); + expect(uri.queryParameters['Container'], 'mp4'); + }); + + test('playback initialization ignores mismatched negotiated source', () async { + 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-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'PlaySessionId': 'wrong-session', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + 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(result.playMethod, 'DirectPlay'); + expect(result.playSessionId, isNull); + final uri = Uri.parse(result.videoUrl!); + expect(uri.path, '/Videos/item-1/stream'); + expect(uri.queryParameters['MediaSourceId'], 'src-1080'); + expect(uri.queryParameters['Container'], 'mp4'); + expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse); + }); + test('getPlaybackInfo path-encodes reserved item id characters', () async { Uri? capturedUri; final scoped = JellyfinClient.forTesting(