fix(jellyfin): pin merged version playback

This commit is contained in:
edde746
2026-05-20 05:27:48 +02:00
parent 63e807dd58
commit 2b167a60f5
4 changed files with 219 additions and 20 deletions
@@ -51,32 +51,36 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
Future<String?> 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<DownloadResolution> 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 = <DownloadSubtitleSpec>[];
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<String, dynamic>) {
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<String, dynamic>? _selectDownloadMediaSource(List<dynamic> sources, String? selectedSourceId, int mediaIndex) {
final requestedSourceId = selectedSourceId?.trim();
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
for (final source in sources) {
if (source is Map<String, dynamic> &&
(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<String, dynamic> ? source : null;
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
// Jellyfin paths flow through `_absolutizeImagePath` at the mapper
@@ -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<String, dynamic>? _selectNegotiatedMediaSource(Object? sources, String? selectedSourceId) {
if (sources is! List || sources.isEmpty) return null;
for (final source in sources) {
if (source is Map<String, dynamic> && source['Id'] == selectedSourceId) {
return source;
final requestedSourceId = selectedSourceId?.trim();
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
for (final source in sources) {
if (source is Map<String, dynamic> &&
(source['Id'] as String?)?.toLowerCase() == requestedSourceId.toLowerCase()) {
return source;
}
}
return null;
}
final first = sources.first;
return first is Map<String, dynamic> ? first : null;
+12 -3
View File
@@ -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;
}
}
@@ -141,6 +141,7 @@ void main() {
test('resolveDownload pins direct stream URL and subtitles to selected media source', () async {
final requests = <Uri>[];
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<String, dynamic>;
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<String, dynamic>;
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(