diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 06f89269..74b988df 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -67,7 +67,7 @@ mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals { return isTrack ? buildAudioDirectStreamUrl(item.id) : buildDirectStreamUrl(item.id); } final container = bundle.container; - final pinnedSourceId = bundle.pinnedSourceIdForItem(item.id); + final pinnedSourceId = bundle.pinnedSourceId; return isTrack ? buildAudioDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId) : buildDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId); @@ -90,18 +90,14 @@ mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals { final audioUrl = buildAudioDirectStreamUrl( item.id, container: bundle?.container, - mediaSourceId: bundle?.pinnedSourceIdForItem(item.id), + mediaSourceId: bundle?.pinnedSourceId, ); return DownloadResolution(videoUrl: audioUrl, mediaSourceId: selectedSourceId, externalSubtitles: const []); } // 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: bundle?.pinnedSourceIdForItem(item.id), - ); + final videoUrl = buildDirectStreamUrl(item.id, container: bundle?.container, mediaSourceId: bundle?.pinnedSourceId); // External subtitle sidecars are listed in the per-source MediaStreams. // PlaybackInfo gives us the canonical view including DeliveryUrl when diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 474aa177..57332649 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -129,9 +129,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { /// 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=...`). + /// audio/subtitle streams server-side. Uses the returned `TranscodingUrl` + /// when the caller asked for a capped quality; otherwise — and on any + /// DirectPlay decision — builds the static direct stream URL + /// (`/Videos/{id}/stream?Static=true&api_key=...`) itself, because Jellyfin + /// never returns a direct-play URL of its own. /// /// The returned `MediaSourceInfo` is what the player uses for track-picker /// labels and auto-track selection by language. @@ -226,35 +228,20 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { ); } - 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']; - final directStreamUrl = chosenSource['DirectStreamUrl']; if (!wantsOriginal && 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); + final urlSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId']; + final negotiatedSessionId = negotiation!['PlaySessionId']; + playSessionId = urlSessionId != null && urlSessionId.isNotEmpty + ? urlSessionId + : (negotiatedSessionId is String ? negotiatedSessionId : null); videoUrl = _withApiKey(transcodingUrl); playMethod = 'Transcode'; isTranscoding = true; includeExternalSubtitleDelivery = true; - } else if (directStreamUrl is String && directStreamUrl.isNotEmpty) { - capturePlaySessionId(directStreamUrl); - videoUrl = _withApiKey(directStreamUrl); - playMethod = 'DirectStream'; - // DirectStream remuxes the selected streams into a new container. - // Subtitle streams marked for external delivery are not present in - // that container, so expose their server URLs as sidecars just as we - // do for transcoded playback. True DirectPlay keeps using the - // embedded native tracks and does not incur a sidecar fetch. - includeExternalSubtitleDelivery = true; } else if (!wantsOriginal) { fallbackReason = TranscodeFallbackReason.directPlayOnly; } @@ -273,7 +260,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { includeExternalDelivery: includeExternalSubtitleDelivery, ); mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars); - final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id); + // Jellyfin's streaming endpoint resolves a blank MediaSourceId to its own + // first sorted source, which for an item with alternate versions is a + // different file. Pin the source the negotiation actually settled on, as + // every official client does. + final pinnedSourceId = _normalizedSourceId(effectiveSourceId); videoUrl ??= isTrack ? buildAudioDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId) : buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId); @@ -293,6 +284,14 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { ); } + /// Source ids ride into `MediaSourceId=`, where Jellyfin compares them + /// ordinally and, on a miss, parses them as a GUID. Only ever forward a + /// non-empty id the server itself gave us; a blank one must stay absent. + static String? _normalizedSourceId(String? sourceId) { + final id = sourceId?.trim(); + return id == null || id.isEmpty ? null : id; + } + int? _validJellyfinAudioStreamId(int? explicit, MediaSourceInfo mediaInfo) { if (explicit == null) return null; return mediaInfo.audioTracks.any((track) => track.id == explicit) ? explicit : null; diff --git a/lib/services/jellyfin_playback_bundle.dart b/lib/services/jellyfin_playback_bundle.dart index 7b2865c1..673c8640 100644 --- a/lib/services/jellyfin_playback_bundle.dart +++ b/lib/services/jellyfin_playback_bundle.dart @@ -25,9 +25,10 @@ class JellyfinPlaybackBundle { /// `buildDirectStreamUrl` so the player gets the right extension hint. final String? container; - /// `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. + /// `Id` of the selected source. Always forwarded as `MediaSourceId=`, as + /// every official Jellyfin client does: the streaming endpoint resolves a + /// blank one to its own first sorted source (`VideoFile` first, then widest + /// video), which for an item with alternate versions is a different file. final String? selectedSourceId; /// Effective source index after source-id matching and range clamping. @@ -47,12 +48,12 @@ class JellyfinPlaybackBundle { 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) { + /// Source id to pin in playback/download URLs, or null when the server did + /// not name one. Never synthesize an id here: Jellyfin compares + /// `MediaSourceId` ordinally and then parses it as a GUID, so a fabricated + /// value turns a working request into a 400/500. + String? get pinnedSourceId { final id = selectedSourceId?.trim(); - if (id == null || id.isEmpty) return null; - return availableVersions.length > 1 || id != itemId ? id : null; + return id == null || id.isEmpty ? null : id; } } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 72812b0a..97b23b9c 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -834,60 +834,7 @@ void main() { expect(tracks.singleWhere((track) => track.id == 3).selected, isTrue); }); - test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async { - final scoped = JellyfinClient.forTesting( - connection: _conn(), - httpClient: MockClient((request) async { - if (request.url.path == '/Users/user-1/Items/item-1') { - return jsonResponse({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }); - } - if (request.url.path == '/Items/item-1/PlaybackInfo') { - return jsonResponse({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }); - } - return http.Response('{}', 404); - }), - ); - addTearDown(scoped.close); - - final result = await scoped.getPlaybackInitialization( - PlaybackInitializationOptions( - metadata: testMediaItem( - id: 'item-1', - backend: MediaBackend.jellyfin, - kind: MediaKind.movie, - serverId: 'srv-1', - ), - selectedMediaIndex: 0, - qualityPreset: TranscodeQualityPreset.p720_2mbps, - ), - ); - - expect(result.isTranscoding, isFalse); - expect(result.playMethod, 'DirectStream'); - expect(result.fallbackReason, isNull); - expect(result.playSessionId, 'play-session-direct'); - final uri = Uri.parse(result.videoUrl!); - expect(uri.path, '/Videos/item-1/stream'); - expect(uri.queryParameters['PlaySessionId'], 'play-session-direct'); - expect(uri.queryParameters['api_key'], 'tok-abc'); - }); - - test('getPlaybackInitialization prefers DirectStreamUrl over TranscodingUrl for original playback', () async { + test('getPlaybackInitialization keeps original playback on the static stream with no bitrate cap', () async { final requests = []; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( @@ -919,7 +866,7 @@ void main() { 'Id': 'src-1', 'Container': 'mp4', 'DefaultAudioStreamIndex': 1, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + // Jellyfin never returns a direct-play URL, only this one. 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', 'MediaStreams': [ @@ -967,14 +914,14 @@ void main() { expect(profile.containsKey('MaxStreamingBitrate'), isFalse); expect(result.isTranscoding, isFalse); - expect(result.playMethod, 'DirectStream'); - expect(result.playSessionId, 'play-session-direct'); + expect(result.playMethod, 'DirectPlay'); + expect(result.playSessionId, isNull); expect(result.activeAudioStreamId, isNull); expect(result.mediaInfo!.audioTracks.single.selected, isTrue); final uri = Uri.parse(result.videoUrl!); expect(uri.path, '/Videos/item-1/stream'); - expect(uri.queryParameters['PlaySessionId'], 'play-session-direct'); - expect(uri.queryParameters['PlaySessionId'], isNot('play-session-transcode')); + expect(uri.queryParameters['MediaSourceId'], 'src-1'); + expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse); expect(uri.queryParameters['api_key'], 'tok-abc'); expect(result.mediaInfo!.subtitleTracks, hasLength(1)); expect(result.externalSubtitles, hasLength(1)); @@ -1020,7 +967,7 @@ void main() { 'Id': 'src-1', 'Container': 'mkv', 'DefaultSubtitleStreamIndex': 4, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-direct', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, { @@ -1072,6 +1019,10 @@ void main() { ), selectedMediaIndex: 0, preferredSubtitleTrack: preference, + // Transcoded playback is the case where the server delivers these + // streams out-of-band; direct play keeps the embedded tracks and is + // covered by the sidecar-identity test above. + qualityPreset: TranscodeQualityPreset.p720_2mbps, ), ); } @@ -1120,7 +1071,7 @@ void main() { ); expectRequestedSubtitleIndex(null); - expect(result.playMethod, 'DirectStream'); + expect(result.playMethod, 'Transcode'); expect(result.mediaInfo!.subtitleTracks, hasLength(3)); expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue); expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]); @@ -1200,6 +1151,69 @@ void main() { expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse); }); + test('single-source direct play still pins MediaSourceId when the source id equals the item id', () async { + // The real-world shape for an ordinary Jellyfin episode: exactly one + // MediaSource whose Id is the item's own GUID. Plezy used to drop + // MediaSourceId here, leaving Jellyfin to resolve its own first sorted + // source — a different file as soon as the item gains an alternate + // version. Every official client sends it unconditionally. + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'MediaSources': [ + { + 'Id': 'item-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'item-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + serverId: 'srv-1', + ), + selectedMediaIndex: 0, + ), + ); + + expect(result.playMethod, 'DirectPlay'); + final uri = Uri.parse(result.videoUrl!); + expect(uri.path, '/Videos/item-1/stream'); + expect(uri.queryParameters['Static'], 'true'); + expect(uri.queryParameters['MediaSourceId'], 'item-1'); + expect(uri.queryParameters['Container'], 'mkv'); + }); + test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async { Uri? playbackInfoUri; String? playbackInfoBody; @@ -2061,7 +2075,7 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); - test('negotiated bare relative DirectStreamUrl preserves reverse-proxy subpaths', () async { + test('negotiated bare relative TranscodingUrl preserves reverse-proxy subpaths', () async { final scoped = JellyfinClient.forTesting( connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: MockClient((request) async { @@ -2081,7 +2095,7 @@ void main() { 'MediaSources': [ { 'Id': 'src-1', - 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'TranscodingUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', }, ], });