fix(jellyfin): pin merged version playback
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user