fix(jellyfin): handle external transcode subtitles

This commit is contained in:
edde746
2026-05-03 10:12:11 +02:00
parent 70606c8ac5
commit aa3c83bad1
8 changed files with 240 additions and 44 deletions
+8 -4
View File
@@ -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 = <String>[];
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).
+7 -5
View File
@@ -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,
+3 -1
View File
@@ -30,6 +30,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;
return (
type: (s['Type'] as String?)?.toLowerCase(),
index: flexibleInt(s['Index']) ?? fallbackIndex,
@@ -40,7 +42,7 @@ 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: s['IsExternal'] as bool? ?? false,
isExternal: isExternal,
deliveryUrl: s['DeliveryUrl'] as String?,
channels: flexibleInt(s['Channels']),
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
+55 -27
View File
@@ -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 = <SubtitleTrack>[];
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<String, dynamic> ? sources.first as Map<String, dynamic> : 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<SubtitleTrack> _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
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 <Map<String, Object?>>[
{'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<String, dynamic>) 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,
),
);
}
+71 -2
View File
@@ -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<String> _subtitleCodecAliases(String? codec) {
final aliases = <String>{
'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 = <String>[];
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);
}
}
+50 -5
View File
@@ -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 = <Uri>[];
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<dynamic>;
expect(
subtitleProfiles.map((profile) => (profile as Map<String, dynamic>)['Format']),
containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']),
);
expect(subtitleProfiles.every((profile) => (profile as Map<String, dynamic>)['Method'] == 'External'), isTrue);
});
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
@@ -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': [
+28
View File
@@ -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',
);
});
});
}