diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index ffeb3210..dddc2859 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -94,6 +94,7 @@ class MediaAudioTrack with _TrackLabelMixin { final String? displayTitle; final int? channels; final bool selected; + final bool external; MediaAudioTrack({ required this.id, @@ -105,8 +106,11 @@ class MediaAudioTrack with _TrackLabelMixin { this.displayTitle, this.channels, required this.selected, + this.external = false, }); + bool get isExternal => external; + String get label { final additionalParts = []; if (codec != null) additionalParts.add(CodecUtils.formatAudioCodec(codec!)); diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 5493a622..6ec5df62 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -192,9 +192,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _effectiveIsOffline = result.isOffline; _playbackPlaySessionId = result.playSessionId; _playbackPlayMethod = result.playMethod; - if (result.activeAudioStreamId != null) { - _selectedAudioStreamId = result.activeAudioStreamId; - } + _selectedAudioStreamId = result.activeAudioStreamId; if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { if (mounted) { showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 306dacb0..3bbd9783 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -163,9 +163,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _effectiveIsOffline = result.isOffline; _playbackPlaySessionId = result.playSessionId; _playbackPlayMethod = result.playMethod; - if (result.activeAudioStreamId != null) { - _selectedAudioStreamId = result.activeAudioStreamId; - } + _selectedAudioStreamId = result.activeAudioStreamId; if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { if (mounted) { diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index c0f44172..51c08bae 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -213,6 +213,7 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader { displayTitle: f.displayTitle, channels: f.channels, selected: f.isDefault, + external: f.isExternal, ); } diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index ed659d3b..a1438dee 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -9,10 +9,11 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { String? mediaSourceId, String? playSessionId, String? liveStreamId, + int? audioStreamIndex, }); Future?> getPlaybackInfo( String itemId, { - int maxStreamingBitrate = 100000000, + int? maxStreamingBitrate = 100000000, String? mediaSourceId, String? liveStreamId, int? audioStreamIndex, diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 985bebe7..e84fa7e1 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -122,14 +122,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// Jellyfin playback URL resolution. /// - /// Two paths: - /// * `qualityPreset.isOriginal` → direct stream - /// (`/Videos/{id}/stream?Static=true&api_key=...`). - /// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the - /// preset's bitrate and use the server-computed `TranscodingUrl` - /// from the returned `MediaSources` entry. Falls back to direct stream - /// when the server didn't provide a transcode URL (e.g. direct play - /// fits the cap) or the negotiation request failed. + /// 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. @@ -148,14 +144,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { chapters: bundle.chapters, trickplay: bundle.trickplay, ); - var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo); - - // 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 = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id - ? bundle.selectedSourceId - : null; + var effectiveSourceId = bundle.selectedSourceId; + var effectiveContainer = bundle.container; + var externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo); String? videoUrl; String? playSessionId; @@ -164,72 +155,76 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { TranscodeFallbackReason? fallbackReason; final preset = options.qualityPreset; - if (!preset.isOriginal && preset.videoBitrateKbps != null) { - final maxBps = preset.videoBitrateKbps! * 1000; - final negotiation = await getPlaybackInfo( - metadata.id, - maxStreamingBitrate: maxBps, - mediaSourceId: bundle.selectedSourceId, - audioStreamIndex: options.selectedAudioStreamId, - ); - if (negotiation == null) { + 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 sources = negotiation['MediaSources']; - Map? chosenSource; - if (sources is List && sources.isNotEmpty) { - for (final src in sources) { - if (src is Map && src['Id'] == bundle.selectedSourceId) { - chosenSource = src; - break; - } - } - chosenSource ??= sources.first is Map ? sources.first as Map : null; - } - final chosenStreams = chosenSource?['MediaStreams']; - if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) { + } + } 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, - chosenSource['Id'] as String? ?? bundle.selectedSourceId, - mediaInfo, - ); + externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo); } - final transcodingUrl = chosenSource?['TranscodingUrl']; + + 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. - playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId']; - final negotiatedPlaySessionId = negotiation['PlaySessionId']; - if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { - playSessionId = negotiatedPlaySessionId; - } + capturePlaySessionId(transcodingUrl); videoUrl = _withApiKey(transcodingUrl); playMethod = 'Transcode'; isTranscoding = true; } else { - final directStreamUrl = chosenSource?['DirectStreamUrl']; + final directStreamUrl = chosenSource['DirectStreamUrl']; if (directStreamUrl is String && directStreamUrl.isNotEmpty) { - playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId']; - final negotiatedPlaySessionId = negotiation['PlaySessionId']; - if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { - playSessionId = negotiatedPlaySessionId; - } + capturePlaySessionId(directStreamUrl); videoUrl = _withApiKey(directStreamUrl); playMethod = 'DirectStream'; - } else { + } else if (!preset.isOriginal) { fallbackReason = TranscodeFallbackReason.directPlayOnly; } } + } else if (!preset.isOriginal) { + fallbackReason = TranscodeFallbackReason.directPlayOnly; } } - videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId); + 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, @@ -239,12 +234,71 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { isOffline: false, isTranscoding: isTranscoding, fallbackReason: fallbackReason, - activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null, + 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? _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 first = sources.first; + return first is Map ? 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; @@ -322,6 +376,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { String? mediaSourceId, String? playSessionId, String? liveStreamId, + int? audioStreamIndex, }) { return buildJellyfinDirectStreamUrl( baseUrl: connection.baseUrl, @@ -332,6 +387,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { mediaSourceId: mediaSourceId, playSessionId: playSessionId, liveStreamId: liveStreamId, + audioStreamIndex: audioStreamIndex, ); } @@ -356,16 +412,17 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// server's recommended `PlaySessionId`. Caller decides which media source /// to use and feeds the returned `TranscodingUrl` into the player. /// - /// [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. [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). + /// 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?> getPlaybackInfo( String itemId, { - int maxStreamingBitrate = 100000000, + int? maxStreamingBitrate = 100000000, String? mediaSourceId, String? liveStreamId, int? audioStreamIndex, @@ -380,7 +437,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { try { final query = { 'userId': connection.userId, - 'MaxStreamingBitrate': maxStreamingBitrate.toString(), + 'MaxStreamingBitrate': ?maxStreamingBitrate?.toString(), 'MediaSourceId': ?mediaSourceId, 'LiveStreamId': ?liveStreamId, 'AudioStreamIndex': ?audioStreamIndex?.toString(), @@ -397,7 +454,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { queryParameters: query, body: { 'UserId': connection.userId, - 'MaxStreamingBitrate': maxStreamingBitrate, + 'MaxStreamingBitrate': ?maxStreamingBitrate, 'MediaSourceId': ?mediaSourceId, 'LiveStreamId': ?liveStreamId, 'AudioStreamIndex': ?audioStreamIndex, @@ -410,7 +467,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { 'AllowAudioStreamCopy': ?allowAudioStreamCopy, 'DeviceProfile': { 'Name': 'Plezy', - 'MaxStreamingBitrate': maxStreamingBitrate, + 'MaxStreamingBitrate': ?maxStreamingBitrate, 'CodecProfiles': const >[], // Comma-separated codec lists are order-sensitive — first entry // wins when the server picks an output codec. HEVC is listed diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 8674cf70..084b5bba 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -149,6 +149,7 @@ List _withDefaultAudioSelection(List tracks, i displayTitle: track.displayTitle, channels: track.channels, selected: track.index == defaultStreamIndex, + external: track.external, ), ]; } diff --git a/lib/services/jellyfin_playback_urls.dart b/lib/services/jellyfin_playback_urls.dart index 69216b7e..dc1d57ee 100644 --- a/lib/services/jellyfin_playback_urls.dart +++ b/lib/services/jellyfin_playback_urls.dart @@ -7,6 +7,7 @@ String buildJellyfinDirectStreamUrl({ String? mediaSourceId, String? playSessionId, String? liveStreamId, + int? audioStreamIndex, }) { final params = { 'Static': 'true', @@ -16,6 +17,7 @@ String buildJellyfinDirectStreamUrl({ 'MediaSourceId': ?mediaSourceId, 'PlaySessionId': ?playSessionId, 'LiveStreamId': ?liveStreamId, + 'AudioStreamIndex': ?audioStreamIndex?.toString(), }; final encodedItem = Uri.encodeComponent(itemId); return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}'; diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 801bb3ef..dffb3873 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -61,8 +61,7 @@ class PlaybackInitializationResult { /// Non-null when a non-original preset was requested but fallback kicked in. final TranscodeFallbackReason? fallbackReason; - /// The Plex audio stream ID actually passed to the transcoder (`null` when - /// not transcoding or when no audio stream was selectable). + /// Source audio stream ID selected by the backend (`null` when unknown). final int? activeAudioStreamId; /// Server playback session ID that must be echoed in progress/stop reports. diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 5bef83ef..c2388f8c 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -2,6 +2,7 @@ import 'dart:async'; import '../mpv/mpv.dart'; +import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; @@ -322,10 +323,17 @@ class PlaybackProgressTracker { } int? _currentAudioStreamIndex(MediaSourceInfo info) { + final playerAudioTracks = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); + if (metadata.backend == MediaBackend.jellyfin && + (info.audioTracks.any((track) => track.isExternal) || playerAudioTracks.length <= 1)) { + final selectedSourceTrack = _selectedSourceAudioTrack(info); + if (selectedSourceTrack != null) return selectedSourceTrack.id; + } + final track = player.state.track.audio; if (track == null) return null; - final ordinal = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList().indexOf(track); + final ordinal = playerAudioTracks.indexOf(track); if (ordinal >= 0 && ordinal < info.audioTracks.length) return info.audioTracks[ordinal].id; final matched = findPlexTrackForMpvAudio(track, info.audioTracks, allMpvTracks: player.state.tracks.audio); @@ -337,6 +345,18 @@ class PlaybackProgressTracker { return null; } + MediaAudioTrack? _selectedSourceAudioTrack(MediaSourceInfo info) { + for (final track in info.audioTracks) { + if (track.selected) return track; + } + final defaultIndex = info.defaultAudioStreamIndex; + if (defaultIndex == null) return null; + for (final track in info.audioTracks) { + if (track.id == defaultIndex) return track; + } + return null; + } + int? _currentSubtitleStreamIndex(MediaSourceInfo info) { final track = player.state.track.subtitle; if (track == null || track.id == 'no') return -1; diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index dc3ae4cf..925809d4 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -26,10 +26,9 @@ class TrackSheet extends StatelessWidget { final Function(SubtitleTrack)? onSubtitleTrackChanged; final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; - /// When true, the audio column renders the Plex [sourceAudioTracks] list - /// and taps are routed to [onSwitchAudioStreamId] instead of using the - /// player's in-stream audio selection (the transcoded stream only has one - /// audio track). + /// When true, or when a Jellyfin source has external audio, the audio column + /// renders [sourceAudioTracks] and taps are routed to [onSwitchAudioStreamId] + /// instead of using the player's in-stream audio selection. final bool isTranscoding; final List sourceAudioTracks; final int? selectedAudioStreamId; @@ -70,7 +69,9 @@ class TrackSheet extends StatelessWidget { (t) => t?.subtitle ?? [], ); - final useSourceAudio = isTranscoding && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null; + final hasExternalSourceAudio = sourceAudioTracks.any((track) => track.isExternal); + final useSourceAudio = + (isTranscoding || hasExternalSourceAudio) && sourceAudioTracks.length > 1 && onSwitchAudioStreamId != null; final showAudio = useSourceAudio || playerAudioTracks.length > 1; final showSubtitles = subtitleTracks.isNotEmpty; @@ -198,7 +199,7 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> { @override Widget build(BuildContext context) { - final selectedId = widget.selectedStreamId; + final selectedId = _effectiveSelectedStreamId(); final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId); _initialScroll.maybeScrollTo(selectedIndex); @@ -228,6 +229,15 @@ class _SourceAudioColumnState extends State<_SourceAudioColumn> { ], ); } + + int? _effectiveSelectedStreamId() { + final explicit = widget.selectedStreamId; + if (explicit != null && widget.tracks.any((track) => track.id == explicit)) return explicit; + for (final track in widget.tracks) { + if (track.selected) return track.id; + } + return null; + } } class _AudioColumn extends StatefulWidget { diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 1e382c3f..fbde6e58 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -68,6 +68,11 @@ void main() { expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2'); }); + test('buildDirectStreamUrl appends AudioStreamIndex when provided', () { + final url = client.buildDirectStreamUrl('item-99', audioStreamIndex: 4); + expect(Uri.parse(url).queryParameters['AudioStreamIndex'], '4'); + }); + test('buildDirectStreamUrl omits MediaSourceId by default', () { final url = client.buildDirectStreamUrl('item-99'); expect(Uri.parse(url).queryParameters.containsKey('MediaSourceId'), isFalse); @@ -333,6 +338,225 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); + test('getPlaybackInitialization negotiates original playback and uses returned source media streams', () async { + final requests = []; + String? playbackInfoBody; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + playbackInfoBody = request.body; + return http.Response( + jsonEncode({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 1, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + ], + }, + ], + }), + 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, + ), + ); + + final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo'); + expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse); + expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1'); + final body = jsonDecode(playbackInfoBody!) as Map; + expect(body.containsKey('MaxStreamingBitrate'), isFalse); + final profile = body['DeviceProfile'] as Map; + expect(profile.containsKey('MaxStreamingBitrate'), isFalse); + + expect(result.isTranscoding, isFalse); + expect(result.playMethod, 'DirectStream'); + expect(result.playSessionId, 'play-session-direct'); + 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['api_key'], 'tok-abc'); + expect(result.mediaInfo!.subtitleTracks, hasLength(1)); + expect(result.externalSubtitles, hasLength(1)); + expect(result.externalSubtitles.single.title, 'English'); + final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!); + expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt'); + expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); + }); + + test('selected external audio is sent to PlaybackInfo and fallback direct URL', () 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-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, + ], + }, + ], + }), + 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, + selectedAudioStreamId: 4, + ), + ); + + expect(playbackInfoUri!.queryParameters['AudioStreamIndex'], '4'); + final body = jsonDecode(playbackInfoBody!) as Map; + expect(body['AudioStreamIndex'], 4); + + expect(result.playMethod, 'DirectPlay'); + expect(result.activeAudioStreamId, 4); + final selected = result.mediaInfo!.audioTracks.singleWhere((track) => track.id == 4); + expect(selected.isExternal, isTrue); + expect(selected.selected, isTrue); + final uri = Uri.parse(result.videoUrl!); + expect(uri.queryParameters['AudioStreamIndex'], '4'); + expect(uri.queryParameters['MediaSourceId'], 'src-1'); + expect(uri.queryParameters['Container'], 'mkv'); + }); + + test('stale selected audio stream is not sent for a source without that stream', () 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-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, + ], + }, + { + 'Id': 'src-2', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 8, + 'MediaStreams': [ + {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }), + 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: 1, + selectedAudioStreamId: 4, + ), + ); + + expect(playbackInfoUri!.queryParameters.containsKey('AudioStreamIndex'), isFalse); + final body = jsonDecode(playbackInfoBody!) as Map; + expect(body.containsKey('AudioStreamIndex'), isFalse); + + expect(result.activeAudioStreamId, isNull); + expect(result.mediaInfo!.audioTracks.single.selected, isTrue); + final uri = Uri.parse(result.videoUrl!); + expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse); + expect(uri.queryParameters['MediaSourceId'], 'src-2'); + }); + 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 cb163ade..0076b93e 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -216,6 +216,28 @@ void main() { expect(sub.isExternal, isTrue); }); + test('preserves external Jellyfin audio streams', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + { + 'Index': 2, + 'Type': 'Audio', + 'Codec': 'flac', + 'Language': 'jpn', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Audio/2/Stream.flac', + }, + {'Index': 3, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'spa', 'IsExternal': true}, + ], + }); + + expect(info.audioTracks.map((track) => track.isExternal), [false, true, true]); + expect(info.audioTracks[1].id, 2); + expect(info.audioTracks[1].codec, 'flac'); + expect(info.audioTracks[2].id, 3); + }); + test('external subtitle without DeliveryUrl remains external for URL fallback', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 40ca9272..009614a2 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -515,6 +515,49 @@ void main() { expect(progressSelection.subtitleStreamIndex, -1); }); + test('Jellyfin progress reports selected source audio when player exposes a single output track', () async { + final client = _FakePlexClient(); + const outputAudio = AudioTrack(id: 'audio_0', language: 'jpn'); + const subtitlesOff = SubtitleTrack(id: 'no'); + final player = _FakePlayer( + position: const Duration(seconds: 5), + duration: const Duration(seconds: 100), + tracks: const Tracks( + audio: [outputAudio], + subtitle: [SubtitleTrack(id: 'text_0', language: 'eng')], + ), + track: const TrackSelection(audio: outputAudio, subtitle: subtitlesOff), + ); + final mediaInfo = MediaSourceInfo( + videoUrl: '', + audioTracks: [ + MediaAudioTrack(id: 1, languageCode: 'eng', selected: false), + MediaAudioTrack(id: 4, languageCode: 'jpn', selected: true, external: true), + ], + subtitleTracks: [MediaSubtitleTrack(id: 3, languageCode: 'eng', selected: false, forced: false)], + chapters: const [], + mediaSourceId: 'source-1', + ); + final tracker = PlaybackProgressTracker( + client: client, + metadata: MediaItem(id: '42', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), + player: player, + isOffline: false, + mediaInfo: mediaInfo, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + + final progressSelection = client.playbackStreamSelections[1]; + expect(progressSelection.mediaSourceId, 'source-1'); + expect(progressSelection.audioStreamIndex, 4); + expect(progressSelection.subtitleStreamIndex, -1); + }); + test('stopped reports only resolve media source and do not include selected streams', () async { final client = _FakePlexClient(); const selectedAudio = AudioTrack(id: 'audio_1', language: 'jpn');