Two regressions since 2.9.1 broke subtitles on transcoded playback. Sincea1b6a8971sidecars load with the media behind a 10s open guard, so a subtitle URL the server is slow to serve — Jellyfin extracting an embedded stream while its transcoder spins up — tripped the guard: stop, reopen without subtitles, "Selected subtitles could not be loaded" snackbar, and an emptied subtitle menu. Since2b3853a88every embedded Plex subtitle was handed to the player as a sidecar whose URL is the original container, so a transcode also range-read and demuxed the source over HTTP — for a 40 GB remux, purely to find a subtitle track — which is also why PGS never appeared: the client was handed a container to demux rather than a rendition to play. Delivery is the server's job again, backported from the AVPlayer branch (42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted to main's mpv backend: Plex burns every embedded track (subtitles=burn); only a real external file with a /library/streams key stays a client-fetched sidecar. A burn is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400 to directPlay=1 with burn — and the burn is aimed by selecting the stream on the part first via the selectStreams PUT, because the decision endpoint ignores subtitleStreamID alongside subtitles=burn. An unaimable or undeliverable burn (dvb_teletext) refuses the transcode and falls back to warned direct play rather than welding the wrong language in or silently dropping the caption. Main's per-preset directPlay/directStream pinning is kept; verified against a live PMS that burn works under directStream=0. Jellyfin never offers image formats as External, so bitmaps fall through to Encode and are burned; text External is withheld per request when the effective selection — including the server's DefaultSubtitleStreamIndex — is embedded, and offered when it is a real file, so a file is delivered as a file and never fetched twice. The burned row is excluded from the sidecars; remaining text rows stay extractable, which is how a secondary track still renders over a transcode. Sidecar URLs now use the format extension the endpoint expects instead of the reported codec name. The controls and selection layers learn what burning means: burn eligibility is the codec's property, so burned rows stay selectable in the menu; any change away from a burned selection renegotiates with the server instead of pretending a local switch worked; the visibility shortcut explains itself instead of doing nothing; and the track manager is told when the primary is server-rendered so it stops waiting out a thirty-second deadline for a native track that is already pixels. Verified: analyzer parity, clean_translations --check --strict, full flutter test (5749), and decision-level runs against live Plex and Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn 400, External file delivery, an unchanged no-burn baseline, and a real burn session serving its playlist. The pre-commit aggregate was bypassed for pre-existing main-state findings outside this diff: 21 format-drifted files and three unused test seams in lib/main.dart. close #1738 Refs #1815, #1622.
196 lines
8.5 KiB
Dart
196 lines
8.5 KiB
Dart
part of '../../jellyfin_client.dart';
|
|
|
|
mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals {
|
|
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
|
|
String itemId, {
|
|
int sourceIndex = 0,
|
|
String? sourceId,
|
|
String? preferredSignature,
|
|
});
|
|
String buildDirectStreamUrl(
|
|
String itemId, {
|
|
String? container,
|
|
String? mediaSourceId,
|
|
String? playSessionId,
|
|
String? liveStreamId,
|
|
int? audioStreamIndex,
|
|
});
|
|
String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId});
|
|
Future<Map<String, dynamic>> getPlaybackInfo(
|
|
String itemId, {
|
|
int? maxStreamingBitrate = 100_000_000,
|
|
String? mediaSourceId,
|
|
String? liveStreamId,
|
|
int? startTimeTicks,
|
|
int? audioStreamIndex,
|
|
int? subtitleStreamIndex,
|
|
bool? autoOpenLiveStream,
|
|
bool? enableDirectPlay,
|
|
bool? enableDirectStream,
|
|
bool? enableTranscoding,
|
|
bool? allowVideoStreamCopy,
|
|
bool? allowAudioStreamCopy,
|
|
bool audioProfile,
|
|
bool burnSubtitles,
|
|
});
|
|
String _withApiKey(String urlOrPath);
|
|
|
|
/// [cover] is accepted for interface parity and ignored: `maxWidth`/
|
|
/// `maxHeight` already scale the long axis to fit inside the box, so
|
|
/// Jellyfin never overshoots the way Plex's `minSize=1` transcode does.
|
|
@override
|
|
String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) {
|
|
if (path == null || path.isEmpty) return '';
|
|
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path);
|
|
final params = Map<String, String>.from(uri.queryParameters);
|
|
if (width != null && !params.containsKey('maxWidth') && !params.containsKey('MaxWidth')) {
|
|
params['maxWidth'] = '$width';
|
|
}
|
|
if (height != null && !params.containsKey('maxHeight') && !params.containsKey('MaxHeight')) {
|
|
params['maxHeight'] = '$height';
|
|
}
|
|
params.putIfAbsent('api_key', () => connection.accessToken);
|
|
return uri.replace(queryParameters: params).toString();
|
|
}
|
|
|
|
/// Jellyfin doesn't expose an external-URL proxy endpoint comparable to
|
|
/// Plex's `/photo/:/transcode?url=...`. External URLs pass through.
|
|
@override
|
|
String externalImageUrl(String url, {int? width, int? height, bool cover = true}) => url;
|
|
|
|
@override
|
|
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
|
|
// Tracks stream from /Audio/{id}/stream; the URL contract (Static=true,
|
|
// api_key in the query string) is otherwise identical to the video one.
|
|
final isTrack = item.kind == MediaKind.track;
|
|
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId);
|
|
if (bundle == null) {
|
|
return isTrack ? buildAudioDirectStreamUrl(item.id) : buildDirectStreamUrl(item.id);
|
|
}
|
|
final container = bundle.container;
|
|
final pinnedSourceId = bundle.pinnedSourceId;
|
|
return isTrack
|
|
? buildAudioDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId)
|
|
: buildDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId);
|
|
}
|
|
|
|
@override
|
|
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
|
|
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId);
|
|
final selectedSourceId = bundle?.selectedSourceId;
|
|
final requestedSourceId = mediaSourceId?.trim();
|
|
if (requestedSourceId != null &&
|
|
requestedSourceId.isNotEmpty &&
|
|
selectedSourceId?.toLowerCase() != requestedSourceId.toLowerCase()) {
|
|
throw StateError('Requested Jellyfin download source is no longer available');
|
|
}
|
|
|
|
// Tracks download from the audio static-stream endpoint and have no
|
|
// subtitle sidecars to enumerate.
|
|
if (item.kind == MediaKind.track) {
|
|
final audioUrl = buildAudioDirectStreamUrl(
|
|
item.id,
|
|
container: bundle?.container,
|
|
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?.pinnedSourceId);
|
|
|
|
// External subtitle sidecars are listed in the per-source MediaStreams.
|
|
// PlaybackInfo gives us the canonical view including DeliveryUrl when
|
|
// the server has pre-computed one; fall back to the documented stream
|
|
// URL pattern otherwise. Negotiation is enrichment only: the static
|
|
// stream URL above remains valid without it.
|
|
final subtitles = <DownloadSubtitleSpec>[];
|
|
Map<String, dynamic> playbackInfo;
|
|
try {
|
|
playbackInfo = await getPlaybackInfo(item.id, mediaSourceId: selectedSourceId);
|
|
} catch (error, stackTrace) {
|
|
if (!_canUseJellyfinStaticStreamFallback(error)) rethrow;
|
|
appLogger.w(
|
|
'Jellyfin download subtitle enrichment unavailable; using the static stream',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false);
|
|
}
|
|
|
|
final source = _selectDownloadMediaSource(playbackInfo['MediaSources'] as List, selectedSourceId, mediaIndex);
|
|
if (source == null) {
|
|
appLogger.w('Jellyfin download subtitle enrichment returned no usable source; using the static stream');
|
|
return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false);
|
|
}
|
|
if (source['MediaStreams'] is! List) {
|
|
appLogger.w('Jellyfin download subtitle enrichment returned malformed streams; using the static stream');
|
|
return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false);
|
|
}
|
|
|
|
final streams = source['MediaStreams'] as List;
|
|
final rawMediaSourceId = source['Id'];
|
|
if (rawMediaSourceId != null && rawMediaSourceId is! String) {
|
|
appLogger.w('Jellyfin download subtitle enrichment returned an invalid source id; using the static stream');
|
|
return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitlesResolved: false);
|
|
}
|
|
final subtitleMediaSourceId = rawMediaSourceId as String? ?? item.id;
|
|
for (final raw in streams) {
|
|
if (raw is! Map<String, dynamic>) continue;
|
|
if (raw['Type'] != 'Subtitle') continue;
|
|
final fields = parseJellyfinStreamFields(raw);
|
|
if (!fields.isExternalFile) continue;
|
|
final index = raw['Index'];
|
|
if (index is! int) continue;
|
|
final codec = fields.codec?.toLowerCase();
|
|
final delivery = fields.deliveryUrl;
|
|
final url = _withApiKey(
|
|
delivery != null && delivery.isNotEmpty
|
|
? delivery
|
|
: '/Videos/${_segment(item.id)}/${_segment(subtitleMediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}',
|
|
);
|
|
subtitles.add(
|
|
DownloadSubtitleSpec(
|
|
id: index,
|
|
url: url,
|
|
codec: codec,
|
|
language: fields.language,
|
|
languageCode: fields.languageCode,
|
|
forced: fields.isForced,
|
|
displayTitle: fields.displayTitle,
|
|
),
|
|
);
|
|
}
|
|
|
|
return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitles: subtitles);
|
|
}
|
|
|
|
Map<String, dynamic>? _selectDownloadMediaSource(List<dynamic> sources, String? selectedSourceId, int mediaIndex) {
|
|
if (sources.isEmpty) return null;
|
|
final requestedSourceId = selectedSourceId?.trim();
|
|
if (requestedSourceId != null && requestedSourceId.isNotEmpty) {
|
|
for (final source in sources) {
|
|
if (source is! Map<String, dynamic>) continue;
|
|
final sourceId = source['Id'];
|
|
if (sourceId is String && sourceId.toLowerCase() == requestedSourceId.toLowerCase()) {
|
|
return source;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
final source = mediaIndex >= 0 && mediaIndex < sources.length ? sources[mediaIndex] : sources.first;
|
|
if (source is! Map<String, dynamic>) return null;
|
|
return source;
|
|
}
|
|
|
|
@override
|
|
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
|
|
// Jellyfin paths flow through `_absolutizeImagePath` at the mapper
|
|
// boundary, so artwork fields on the [MediaItem] are already absolute
|
|
// URLs. buildArtworkSpecs strips auth query params from localKey so the
|
|
// storage layer never hashes or persists access tokens.
|
|
return buildArtworkSpecs(item, (path) => path);
|
|
}
|
|
}
|