diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 09947ee7..67583e0d 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -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 { diff --git a/lib/media/media_stream.dart b/lib/media/media_stream.dart index b3a193f9..e03e43a2 100644 --- a/lib/media/media_stream.dart +++ b/lib/media/media_stream.dart @@ -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({ diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index 51c08bae..ce8b5f35 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -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 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 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, ); } } diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index b21da7a2..a49637bc 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -89,7 +89,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { if (raw is! Map) 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(); diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index e409232f..37ae88e0 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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 _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) { + List _buildExternalSubtitles( + String itemId, + String? mediaSourceId, + MediaSourceInfo mediaInfo, { + bool includeExternalDelivery = false, + }) { final externalSubtitles = []; 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 diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index dba52913..daa83a53 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -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, ), ); } diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 4795169d..26ff9a08 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -169,6 +169,7 @@ List _withDefaultSubtitleSelection(List forced: track.forced, key: track.key, external: track.external, + usesExternalDelivery: track.usesExternalDelivery, ), ]; } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 3183ac91..28306fa2 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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 = []; final scoped = JellyfinClient.forTesting( diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index bbffde8b..a24ccf0a 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -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', () {