fix(jellyfin): avoid side-loading negotiated subtitles

This commit is contained in:
edde746
2026-05-25 23:39:55 +02:00
parent cec8993b80
commit 8e53232961
9 changed files with 132 additions and 14 deletions
+5 -1
View File
@@ -135,6 +135,7 @@ class MediaSubtitleTrack with _TrackLabelMixin {
final bool forced;
final String? key;
final bool external;
final bool usesExternalDelivery;
MediaSubtitleTrack({
required this.id,
@@ -148,6 +149,7 @@ class MediaSubtitleTrack with _TrackLabelMixin {
required this.forced,
this.key,
this.external = false,
this.usesExternalDelivery = false,
});
String get label {
@@ -178,7 +180,9 @@ class MediaSubtitleTrack with _TrackLabelMixin {
/// Returns true if this subtitle track is an external file (sidecar subtitle).
/// Some backends provide a direct key/URL, others require constructing one
/// from stream metadata.
bool get isExternal => external || (key != null && key!.isNotEmpty);
bool get isExternalFile => external;
bool get isExternal => external || usesExternalDelivery || (key != null && key!.isNotEmpty);
}
class MediaChapter {
+3 -4
View File
@@ -23,10 +23,9 @@ class MediaStream {
// Subtitle
final bool forced;
/// Backend-resolved location for sidecar subtitle download. For Plex this
/// is the Plex-specific `/library/streams/{id}` path; for Jellyfin this is
/// the `DeliveryUrl` returned by `/Items/{id}/PlaybackInfo`. Null for
/// embedded streams.
/// Backend-resolved location for true sidecar subtitle download. Null for
/// embedded streams, even when Jellyfin can expose them through temporary
/// external delivery URLs during playback negotiation.
final String? sidecarPath;
const MediaStream({
+9 -3
View File
@@ -22,6 +22,8 @@ typedef JellyfinStreamFields = ({
String? displayTitle,
bool isDefault,
bool isForced,
bool isExternalFile,
bool usesExternalDelivery,
bool isExternal,
String? deliveryUrl,
int? channels,
@@ -30,7 +32,8 @@ typedef JellyfinStreamFields = ({
JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fallbackIndex = 0}) {
final deliveryMethod = (s['DeliveryMethod'] as String?)?.toLowerCase();
final isExternal = deliveryMethod != null ? deliveryMethod == 'external' : s['IsExternal'] == true;
final isExternalFile = s['IsExternal'] == true;
final usesExternalDelivery = deliveryMethod == 'external';
return (
type: (s['Type'] as String?)?.toLowerCase(),
index: flexibleInt(s['Index']) ?? fallbackIndex,
@@ -41,7 +44,9 @@ JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fall
displayTitle: s['DisplayTitle'] as String?,
isDefault: s['IsDefault'] as bool? ?? false,
isForced: s['IsForced'] as bool? ?? false,
isExternal: isExternal,
isExternalFile: isExternalFile,
usesExternalDelivery: usesExternalDelivery,
isExternal: isExternalFile || usesExternalDelivery,
deliveryUrl: s['DeliveryUrl'] as String?,
channels: flexibleInt(s['Channels']),
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
@@ -231,7 +236,8 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
selected: f.isDefault,
forced: f.isForced,
key: f.isExternal ? f.deliveryUrl : null,
external: f.isExternal,
external: f.isExternalFile,
usesExternalDelivery: f.usesExternalDelivery,
);
}
}
@@ -89,7 +89,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
if (raw is! Map<String, dynamic>) continue;
if (raw['Type'] != 'Subtitle') continue;
final fields = parseJellyfinStreamFields(raw);
if (!fields.isExternal) continue;
if (!fields.isExternalFile) continue;
final index = raw['Index'];
if (index is! int) continue;
final codec = fields.codec?.toLowerCase();
@@ -150,7 +150,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
);
var effectiveSourceId = bundle.selectedSourceId;
var effectiveContainer = bundle.container;
var externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo);
var includeExternalSubtitleDelivery = false;
String? videoUrl;
String? playSessionId;
@@ -187,7 +187,6 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
externalSubtitles = _buildExternalSubtitles(metadata.id, effectiveSourceId, mediaInfo);
}
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
@@ -208,6 +207,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
includeExternalSubtitleDelivery = true;
} else if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
capturePlaySessionId(directStreamUrl);
videoUrl = _withApiKey(directStreamUrl);
@@ -224,6 +224,12 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
final externalSubtitles = _buildExternalSubtitles(
metadata.id,
effectiveSourceId,
mediaInfo,
includeExternalDelivery: includeExternalSubtitleDelivery,
);
final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id);
videoUrl ??= buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId);
@@ -316,10 +322,15 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
return path.startsWith('/') ? path : '/$path';
}
List<SubtitleTrack> _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) {
List<SubtitleTrack> _buildExternalSubtitles(
String itemId,
String? mediaSourceId,
MediaSourceInfo mediaInfo, {
bool includeExternalDelivery = false,
}) {
final externalSubtitles = <SubtitleTrack>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternal) continue;
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) continue;
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
+1 -1
View File
@@ -424,7 +424,7 @@ class JellyfinMappers {
channels: f.channels,
frameRate: f.frameRate,
forced: f.isForced,
sidecarPath: f.isExternal ? f.deliveryUrl : null,
sidecarPath: f.isExternalFile ? f.deliveryUrl : null,
),
);
}
@@ -169,6 +169,7 @@ List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack>
forced: track.forced,
key: track.key,
external: track.external,
usesExternalDelivery: track.usesExternalDelivery,
),
];
}
@@ -177,9 +177,20 @@ void main() {
'Language': 'eng',
'DisplayLanguage': 'English',
'DisplayTitle': 'English - SRT',
'IsExternal': true,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt',
},
{
'Index': 4,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'fra',
'DisplayLanguage': 'French',
'DisplayTitle': 'French - SRT',
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt',
},
],
},
],
@@ -332,6 +343,8 @@ void main() {
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue);
expect(result.externalSubtitles, hasLength(1));
expect(result.externalSubtitles.single.title, 'English');
expect(result.externalSubtitles.single.language, 'eng');
@@ -444,6 +457,7 @@ void main() {
'Codec': 'srt',
'Language': 'eng',
'DisplayTitle': 'English - SRT',
'IsExternal': true,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
},
@@ -495,6 +509,85 @@ void main() {
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization skips negotiated subtitle delivery for original playback', () 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-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
],
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
return http.Response(
jsonEncode({
'PlaySessionId': 'play-session-direct',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
{
'Index': 3,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'eng',
'DisplayTitle': 'English - SRT',
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
},
{
'Index': 4,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'fra',
'DisplayTitle': 'French - SRT',
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/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,
),
);
expect(result.playMethod, 'DirectStream');
expect(result.mediaInfo!.subtitleTracks, hasLength(2));
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
expect(result.externalSubtitles, isEmpty);
});
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
final requests = <Uri>[];
final scoped = JellyfinClient.forTesting(
@@ -214,6 +214,8 @@ void main() {
final sub = info.subtitleTracks.single;
expect(sub.key, '/Videos/item-1/src-1/Subtitles/2/Stream.srt');
expect(sub.isExternal, isTrue);
expect(sub.isExternalFile, isFalse);
expect(sub.usesExternalDelivery, isTrue);
});
test('preserves external Jellyfin audio streams', () {
@@ -247,6 +249,8 @@ void main() {
final sub = info.subtitleTracks.single;
expect(sub.key, isNull);
expect(sub.isExternal, isTrue);
expect(sub.isExternalFile, isTrue);
expect(sub.usesExternalDelivery, isFalse);
});
test('captures mediaSourceId from source Id field', () {