fix(jellyfin): support external audio playback
This commit is contained in:
@@ -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 = <Uri>[];
|
||||
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<String, dynamic>;
|
||||
expect(body.containsKey('MaxStreamingBitrate'), isFalse);
|
||||
final profile = body['DeviceProfile'] as Map<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
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(
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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<void>.delayed(Duration.zero);
|
||||
await tracker.sendProgress('playing');
|
||||
await Future<void>.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');
|
||||
|
||||
Reference in New Issue
Block a user