From aa3c83bad1619287b728d1157971402d7adcd643 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 3 May 2026 10:12:11 +0200 Subject: [PATCH] fix(jellyfin): handle external transcode subtitles --- lib/media/media_source_info.dart | 12 ++- lib/mpv/player/player_base.dart | 12 +-- lib/services/file_info_parser.dart | 4 +- lib/services/jellyfin_client.dart | 82 +++++++++++++------- lib/utils/track_label_builder.dart | 73 ++++++++++++++++- test/services/jellyfin_client_urls_test.dart | 55 +++++++++++-- test/services/jellyfin_media_info_test.dart | 18 +++++ test/utils/track_label_builder_test.dart | 28 +++++++ 8 files changed, 240 insertions(+), 44 deletions(-) diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index e8cade9c..e5bb6141 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -1,5 +1,5 @@ import '../utils/codec_utils.dart'; -import '../utils/track_label_builder.dart' show buildTrackLabel; +import '../utils/track_label_builder.dart' show TrackLabelBuilder, buildTrackLabel; class MediaSourceInfo { final String videoUrl; @@ -149,9 +149,13 @@ class MediaSubtitleTrack with _TrackLabelMixin { }); String get label { - final additionalParts = []; - if (forced) additionalParts.add('Forced'); - return buildLabel(additionalParts); + return TrackLabelBuilder.buildSubtitleLabel( + title: displayTitle ?? title, + language: languageCode ?? language, + codec: codec, + forced: forced, + index: (index ?? id) - 1, + ); } /// Returns true if this subtitle track is an external file (sidecar subtitle). diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 742e67fc..a7066b6a 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show protected; import 'package:flutter/services.dart'; import '../../utils/app_logger.dart'; +import '../../utils/track_label_builder.dart'; import '../font_loader.dart'; import '../models.dart'; import 'player.dart'; @@ -416,8 +417,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { audioTracks.add( AudioTrack( id: id, - title: track['title'] as String?, - language: track['lang'] as String?, + title: cleanTrackMetadataValue(track['title'] as String?), + language: cleanTrackMetadataValue(track['lang'] as String?), codec: track['codec'] as String?, channels: (track['demux-channel-count'] as num?)?.toInt(), sampleRate: (track['demux-samplerate'] as num?)?.toInt(), @@ -426,12 +427,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { ); } else if (type == 'sub') { if (selected) selectedSubtitleId = id; + final codec = track['codec'] as String?; subtitleTracks.add( SubtitleTrack( id: id, - title: track['title'] as String?, - language: track['lang'] as String?, - codec: track['codec'] as String?, + title: cleanSubtitleTitle(track['title'] as String?, codec: codec), + language: cleanTrackMetadataValue(track['lang'] as String?), + codec: codec, isDefault: track['default'] as bool? ?? false, isForced: track['forced'] as bool? ?? false, isExternal: track['external'] as bool? ?? false, diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index fa56e1ca..6043b7c5 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -30,6 +30,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; return ( type: (s['Type'] as String?)?.toLowerCase(), index: flexibleInt(s['Index']) ?? fallbackIndex, @@ -40,7 +42,7 @@ JellyfinStreamFields parseJellyfinStreamFields(Map s, {int fall displayTitle: s['DisplayTitle'] as String?, isDefault: s['IsDefault'] as bool? ?? false, isForced: s['IsForced'] as bool? ?? false, - isExternal: s['IsExternal'] as bool? ?? false, + isExternal: isExternal, deliveryUrl: s['DeliveryUrl'] as String?, channels: flexibleInt(s['Channels']), frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']), diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index a2b26fee..81a85e8a 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -33,6 +33,7 @@ import '../utils/log_redaction_manager.dart'; import '../utils/external_ids.dart'; import '../utils/media_server_http_client.dart'; import '../utils/resolution_label.dart'; +import '../utils/track_label_builder.dart'; import '../utils/watch_state_notifier.dart'; import '../exceptions/media_server_exceptions.dart'; import '../i18n/strings.g.dart'; @@ -610,29 +611,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc if (bundle == null) { throw PlaybackException('Item ${metadata.id} returned no MediaSources'); } - final mediaInfo = jellyfinMediaSourceToMediaSourceInfo( + var mediaInfo = jellyfinMediaSourceToMediaSourceInfo( bundle.selectedSource, chapters: bundle.chapters, trickplay: bundle.trickplay, ); - - final externalSubtitles = []; - for (final track in mediaInfo.subtitleTracks) { - if (track.isExternal) { - final path = track.key ?? _jellyfinSubtitleFallbackPath(metadata.id, bundle.selectedSourceId, track); - if (path == null) continue; - // Jellyfin's subtitle URL is a path relative to baseUrl; build the - // absolute URL with the api_key query param. - final url = _withApiKey(path); - externalSubtitles.add( - SubtitleTrack.uri( - url, - title: track.displayTitle ?? track.title ?? track.language, - language: track.languageCode, - ), - ); - } - } + var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo); // Only forward MediaSourceId when there's actually more than one source — // single-source items have `MediaSourceId == itemId` so the param is a @@ -670,6 +654,19 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc } chosenSource ??= sources.first is Map ? sources.first as Map : null; } + final chosenStreams = chosenSource?['MediaStreams']; + if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) { + mediaInfo = jellyfinMediaSourceToMediaSourceInfo( + chosenSource, + chapters: bundle.chapters, + trickplay: bundle.trickplay, + ); + externalSubtitles = _buildExternalSubtitles( + metadata.id, + chosenSource['Id'] as String? ?? bundle.selectedSourceId, + mediaInfo, + ); + } final transcodingUrl = chosenSource?['TranscodingUrl']; if (transcodingUrl is String && transcodingUrl.isNotEmpty) { // TranscodingUrl is server-relative and already encodes container, @@ -727,6 +724,28 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc return path.startsWith('/') ? path : '/$path'; } + List _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) { + final externalSubtitles = []; + for (final track in mediaInfo.subtitleTracks) { + if (!track.isExternal) 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 + // absolute URL with the api_key query param. + final url = _withApiKey(path); + externalSubtitles.add( + SubtitleTrack.uri( + url, + title: + cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ?? + cleanTrackMetadataValue(track.language), + language: cleanTrackMetadataValue(track.languageCode), + ), + ); + } + return externalSubtitles; + } + /// Internal accessor for [PlaybackInitializationService]. Returns the /// chosen `MediaSource` JSON, every available source's [MediaVersion], /// and the item's `Chapters` array. One round-trip vs. fetchItem + raw @@ -1751,6 +1770,15 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts', }, ], + 'SubtitleProfiles': const >[ + {'Format': 'srt', 'Method': 'External'}, + {'Format': 'ass', 'Method': 'External'}, + {'Format': 'ssa', 'Method': 'External'}, + {'Format': 'vtt', 'Method': 'External'}, + {'Format': 'pgssub', 'Method': 'External'}, + {'Format': 'dvdsub', 'Method': 'External'}, + {'Format': 'dvbsub', 'Method': 'External'}, + ], }, }, ); @@ -2227,12 +2255,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc for (final raw in streams) { if (raw is! Map) continue; if (raw['Type'] != 'Subtitle') continue; - final isExternal = raw['IsExternal'] == true; - if (!isExternal) continue; + final fields = parseJellyfinStreamFields(raw); + if (!fields.isExternal) continue; final index = raw['Index']; if (index is! int) continue; - final codec = (raw['Codec'] as String?)?.toLowerCase(); - final delivery = raw['DeliveryUrl'] as String?; + final codec = fields.codec?.toLowerCase(); + final delivery = fields.deliveryUrl; final url = _withApiKey( delivery != null && delivery.isNotEmpty ? delivery @@ -2243,10 +2271,10 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc id: index, url: url, codec: codec, - language: raw['Language'] as String?, - languageCode: raw['Language'] as String?, - forced: raw['IsForced'] == true, - displayTitle: raw['DisplayTitle'] as String?, + language: fields.language, + languageCode: fields.languageCode, + forced: fields.isForced, + displayTitle: fields.displayTitle, ), ); } diff --git a/lib/utils/track_label_builder.dart b/lib/utils/track_label_builder.dart index ce84ddf5..a975c81b 100644 --- a/lib/utils/track_label_builder.dart +++ b/lib/utils/track_label_builder.dart @@ -19,6 +19,66 @@ String buildTrackLabel({ return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' · '); } +String? cleanTrackMetadataValue(String? value) { + if (value == null) return null; + var cleaned = value.trim(); + if (cleaned.isEmpty) return null; + + final prefixed = RegExp(r'^(?:title|lang|language)\s*=\s*(.*)$', caseSensitive: false).firstMatch(cleaned); + if (prefixed != null) { + cleaned = prefixed.group(1)?.trim() ?? ''; + } + + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.substring(1, cleaned.length - 1).trim(); + } + + return cleaned.isEmpty ? null : cleaned; +} + +String? cleanSubtitleTitle(String? title, {String? codec}) { + var cleaned = cleanTrackMetadataValue(title); + if (cleaned == null) return null; + + final codecAliases = _subtitleCodecAliases(codec); + if (codecAliases.isEmpty) return cleaned; + + final parts = cleaned.split(RegExp(r'\s+-\s+')); + while (parts.isNotEmpty && codecAliases.contains(_metadataToken(parts.last))) { + parts.removeLast(); + } + cleaned = parts.join(' - ').trim(); + + return cleaned.isEmpty ? null : cleaned; +} + +Set _subtitleCodecAliases(String? codec) { + final aliases = { + 'SUBRIP', + 'SRT', + 'WEBVTT', + 'VTT', + 'ASS', + 'SSA', + 'PGS', + 'PGSSUB', + 'HDMV_PGS_SUBTITLE', + 'DVD', + 'DVDSUB', + 'DVD_SUBTITLE', + 'DVB_SUB', + 'DVB_SUBTITLE', + }; + if (codec != null && codec.isNotEmpty) { + aliases.add(_metadataToken(codec)); + aliases.add(_metadataToken(CodecUtils.formatSubtitleCodec(codec))); + aliases.add(_metadataToken(CodecUtils.getSubtitleExtension(codec))); + } + return aliases; +} + +String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]+'), '_'); + /// Utility for building track labels for audio and subtitle tracks. class TrackLabelBuilder { TrackLabelBuilder._(); @@ -52,11 +112,20 @@ class TrackLabelBuilder { /// Build a label for a subtitle track. /// /// Combines title, language, and codec (with friendly codec names). - static String buildSubtitleLabel({String? title, String? language, String? codec, required int index}) { + static String buildSubtitleLabel({ + String? title, + String? language, + String? codec, + bool forced = false, + required int index, + }) { + final cleanedTitle = cleanSubtitleTitle(title, codec: codec); + final cleanedLanguage = cleanTrackMetadataValue(language)?.toUpperCase(); final extraParts = []; + if (forced && !_metadataToken(cleanedTitle ?? '').split('_').contains('FORCED')) extraParts.add('Forced'); if (codec != null && codec.isNotEmpty) { extraParts.add(CodecUtils.formatSubtitleCodec(codec)); } - return buildTrackLabel(title: title, language: language?.toUpperCase(), extraParts: extraParts, index: index); + return buildTrackLabel(title: cleanedTitle, language: cleanedLanguage, extraParts: extraParts, index: index); } } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index b3bbd3c6..fdbb4f4c 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -113,7 +113,7 @@ void main() { expect(body['IsPaused'], isTrue); }); - test('resolveDownload pins direct stream URL to selected media source', () async { + test('resolveDownload pins direct stream URL and subtitles to selected media source', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), @@ -139,7 +139,21 @@ void main() { jsonEncode({ 'MediaSources': [ {'Id': 'src-1', 'MediaStreams': []}, - {'Id': 'src-2', 'MediaStreams': []}, + { + 'Id': 'src-2', + 'MediaStreams': [ + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', + }, + ], + }, ], }), 200, @@ -160,6 +174,14 @@ void main() { expect(uri.queryParameters['MediaSourceId'], 'src-2'); expect(uri.queryParameters['Container'], 'mkv'); expect(requests.map((u) => u.path), contains('/Items/item-1/PlaybackInfo')); + expect(resolution.externalSubtitles, hasLength(1)); + final subtitle = resolution.externalSubtitles.single; + expect(subtitle.id, 3); + expect(subtitle.language, 'English'); + expect(subtitle.languageCode, 'eng'); + final subtitleUri = Uri.parse(subtitle.url); + expect(subtitleUri.path, '/Videos/item-1/src-2/Subtitles/3/Stream.srt'); + expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async { @@ -187,7 +209,18 @@ void main() { { 'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', - 'MediaStreams': [], + 'MediaStreams': [ + {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', + }, + ], }, ], }), @@ -214,6 +247,13 @@ void main() { final uri = Uri.parse(result.videoUrl!); expect(uri.queryParameters['PlaySessionId'], 'play-session-1'); expect(uri.queryParameters['api_key'], 'tok-abc'); + expect(result.mediaInfo!.subtitleTracks, hasLength(1)); + expect(result.externalSubtitles, hasLength(1)); + expect(result.externalSubtitles.single.title, 'English'); + expect(result.externalSubtitles.single.language, 'eng'); + final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!); + expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt'); + expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async { @@ -288,7 +328,7 @@ void main() { expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo')); }); - test('getPlaybackInfo keeps the known-good lean DeviceProfile', () async { + test('getPlaybackInfo advertises external subtitle support', () async { Uri? capturedUri; String? capturedBody; final scoped = JellyfinClient.forTesting( @@ -324,7 +364,12 @@ void main() { expect(profile['DirectPlayProfiles'], isNotEmpty); expect(profile['TranscodingProfiles'], isNotEmpty); expect(profile['CodecProfiles'], isEmpty); - expect(profile.containsKey('SubtitleProfiles'), isFalse); + final subtitleProfiles = profile['SubtitleProfiles'] as List; + expect( + subtitleProfiles.map((profile) => (profile as Map)['Format']), + containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']), + ); + expect(subtitleProfiles.every((profile) => (profile as Map)['Method'] == 'External'), isTrue); }); test('path-encodes reserved ids for browse and watch-state endpoints', () async { diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index 26fdb381..4d938dca 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -145,6 +145,24 @@ void main() { expect(info.subtitleTracks.single.isExternal, isFalse); }); + test('DeliveryMethod External marks negotiated subtitles as external', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'MediaStreams': [ + { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', + }, + ], + }); + + final sub = info.subtitleTracks.single; + expect(sub.key, '/Videos/item-1/src-1/Subtitles/2/Stream.srt'); + expect(sub.isExternal, isTrue); + }); + test('external subtitle without DeliveryUrl remains external for URL fallback', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ diff --git a/test/utils/track_label_builder_test.dart b/test/utils/track_label_builder_test.dart index 7022c57f..602d2a92 100644 --- a/test/utils/track_label_builder_test.dart +++ b/test/utils/track_label_builder_test.dart @@ -92,9 +92,37 @@ void main() { expect(TrackLabelBuilder.buildSubtitleLabel(language: 'en', codec: '', index: 0), 'EN'); }); + test('does not duplicate forced when the title already says forced', () { + expect( + TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', forced: true, index: 0), + 'Forced · EN · SRT', + ); + }); + test('falls back to "Track N" with default prefix', () { expect(TrackLabelBuilder.buildSubtitleLabel(index: 0), 'Track 1'); expect(TrackLabelBuilder.buildSubtitleLabel(index: 7), 'Track 8'); }); + + test('cleans raw Jellyfin/ExoPlayer subtitle metadata prefixes', () { + expect( + TrackLabelBuilder.buildSubtitleLabel( + title: 'title=German - SUBRIP', + language: 'LANG=DEU', + codec: 'srt', + index: 0, + ), + 'German · DEU · SRT', + ); + expect( + TrackLabelBuilder.buildSubtitleLabel( + title: 'title=English - Default - SUBRIP', + language: 'LANG=ENG', + codec: 'subrip', + index: 1, + ), + 'English - Default · ENG · SRT', + ); + }); }); }