fix(jellyfin): pin MediaSourceId on every static stream URL
Jellyfin has no DirectStreamUrl field — MediaSourceInfo carries only
TranscodingUrl, and a DirectPlay decision returns no URL at all, leaving the
client to build /Videos/{id}/stream itself. The branch reading
DirectStreamUrl was therefore dead against every Jellyfin version, along with
the 'DirectStream' play method and the doc comment promising both.
The static URL also dropped MediaSourceId whenever the item had a single
source whose Id equalled the item id — an ordinary episode. The streaming
endpoint resolves a blank MediaSourceId to its own first sorted source
(VideoFile first, then widest video), so the omission silently streamed a
different file as soon as the item gained an alternate version. Forward the
id the negotiation settled on, as jellyfin-web, Findroid, and Streamyfin all
do unconditionally.
Every "pinned" fixture used a source id that differed from the item id, so no
test exercised the shape that dropped the param; add one that does.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <Uri>[];
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user