fix(jellyfin): auto-select direct-played embedded subtitles
Plezy's device profile declares every subtitle format with `Method: External`, so Jellyfin answers PlaybackInfo with `DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded in a direct-played container. Direct play never fetches those URLs, but the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed rows only match a native track loaded from the same URL. No embedded track could satisfy that, so `selectSubtitleTrack` reported "still pending" forever: playback started with subtitles off and logged the five- and thirty-second waits, and the server's default subtitle had to be picked by hand on every item. Restrict sidecar identity to the rows an open actually fetched as sidecars. A row that stays in the container loses `key` and `usesExternalDelivery` and matches on metadata again; genuine `IsExternal` files keep theirs, and remuxed or transcoded renditions still resolve their sidecars by URL. Also declare every subtitle format Embed-first so a direct-played container reports embedded delivery in the first place, and make the pending contract match its purpose on every backend. The complete-catalog escape is no longer Plex-only, so a Jellyfin row the native player has not produced keeps the pass pending instead of committing an unrelated default and retiring the listener that was waiting for the real track. A source id absent from the catalog no longer defers a decision that can never change, and the thirty-second deadline resolves from what has arrived instead of re-deriving the same deferral and applying nothing. close #1696
This commit is contained in:
@@ -41,6 +41,26 @@ class MediaSourceInfo {
|
||||
this.trickplayByWidth,
|
||||
this.videoAspectRatio,
|
||||
});
|
||||
|
||||
/// Field-preserving rebuild. Track lists are the only members that the
|
||||
/// playback pipeline rewrites after construction; every other field must
|
||||
/// survive those rewrites untouched.
|
||||
MediaSourceInfo copyWith({List<MediaAudioTrack>? audioTracks, List<MediaSubtitleTrack>? subtitleTracks}) {
|
||||
return MediaSourceInfo(
|
||||
videoUrl: videoUrl,
|
||||
audioTracks: audioTracks ?? this.audioTracks,
|
||||
subtitleTracks: subtitleTracks ?? this.subtitleTracks,
|
||||
chapters: chapters,
|
||||
partId: partId,
|
||||
displayCriteria: displayCriteria,
|
||||
mediaSourceId: mediaSourceId,
|
||||
defaultAudioStreamIndex: defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
||||
trickplayByWidth: trickplayByWidth,
|
||||
videoAspectRatio: videoAspectRatio,
|
||||
);
|
||||
}
|
||||
|
||||
int? getPartId() => partId;
|
||||
}
|
||||
|
||||
@@ -107,6 +127,22 @@ class MediaAudioTrack with _TrackLabelMixin {
|
||||
|
||||
bool get isExternal => external;
|
||||
|
||||
/// Rebuild with a different server-selected flag.
|
||||
MediaAudioTrack withSelected(bool selected) {
|
||||
return MediaAudioTrack(
|
||||
id: id,
|
||||
index: index,
|
||||
codec: codec,
|
||||
language: language,
|
||||
languageCode: languageCode,
|
||||
title: title,
|
||||
displayTitle: displayTitle,
|
||||
channels: channels,
|
||||
selected: selected,
|
||||
external: external,
|
||||
);
|
||||
}
|
||||
|
||||
TrackLabel get label {
|
||||
return TrackLabelBuilder.audioLabel(
|
||||
title: title,
|
||||
@@ -173,6 +209,35 @@ class MediaSubtitleTrack with _TrackLabelMixin {
|
||||
bool get isExternalFile => external;
|
||||
|
||||
bool get isExternal => external || usesExternalDelivery || (key != null && key!.isNotEmpty);
|
||||
|
||||
/// Rebuild with a different server-selected flag.
|
||||
MediaSubtitleTrack withSelected(bool selected) => _rebuild(selected: selected);
|
||||
|
||||
/// Rebuild without the sidecar identity fields ([key] and
|
||||
/// [usesExternalDelivery]).
|
||||
///
|
||||
/// Whether a row has sidecar identity is a per-playback fact that only the
|
||||
/// backend service layer can establish; this just applies that decision.
|
||||
/// [external] is left untouched for the caller to interpret.
|
||||
MediaSubtitleTrack withoutSidecarIdentity() =>
|
||||
key == null && !usesExternalDelivery ? this : _rebuild(dropSidecarIdentity: true);
|
||||
|
||||
MediaSubtitleTrack _rebuild({bool? selected, bool dropSidecarIdentity = false}) {
|
||||
return MediaSubtitleTrack(
|
||||
id: id,
|
||||
index: index,
|
||||
codec: codec,
|
||||
language: language,
|
||||
languageCode: languageCode,
|
||||
title: title,
|
||||
displayTitle: displayTitle,
|
||||
selected: selected ?? this.selected,
|
||||
forced: forced,
|
||||
key: dropSidecarIdentity ? null : key,
|
||||
external: external,
|
||||
usesExternalDelivery: dropSidecarIdentity ? false : usesExternalDelivery,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaChapter {
|
||||
|
||||
@@ -272,6 +272,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
mediaInfo,
|
||||
includeExternalDelivery: includeExternalSubtitleDelivery,
|
||||
);
|
||||
mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars);
|
||||
final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id);
|
||||
videoUrl ??= isTrack
|
||||
? buildAudioDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId)
|
||||
@@ -375,31 +376,36 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
if (selectedStreamId == null || !mediaInfo.audioTracks.any((track) => track.id == selectedStreamId)) {
|
||||
return mediaInfo;
|
||||
}
|
||||
return MediaSourceInfo(
|
||||
videoUrl: mediaInfo.videoUrl,
|
||||
audioTracks: [
|
||||
for (final track in mediaInfo.audioTracks)
|
||||
MediaAudioTrack(
|
||||
id: track.id,
|
||||
index: track.index,
|
||||
codec: track.codec,
|
||||
language: track.language,
|
||||
languageCode: track.languageCode,
|
||||
title: track.title,
|
||||
displayTitle: track.displayTitle,
|
||||
channels: track.channels,
|
||||
selected: track.id == selectedStreamId,
|
||||
external: track.external,
|
||||
),
|
||||
return mediaInfo.copyWith(
|
||||
audioTracks: [for (final track in mediaInfo.audioTracks) track.withSelected(track.id == selectedStreamId)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Restrict sidecar identity to the subtitle rows this open actually fetched
|
||||
/// as sidecars.
|
||||
///
|
||||
/// Plezy's device profile declares every subtitle format with
|
||||
/// `Method: External`, so Jellyfin returns `DeliveryMethod: External` and a
|
||||
/// `DeliveryUrl` even for streams embedded in a direct-played container.
|
||||
/// [_buildExternalSubtitles] correctly skips those, and the native player
|
||||
/// reads them out of the container instead — but the leftover delivery URL
|
||||
/// makes the shared track matchers demand a sidecar that will never load,
|
||||
/// which leaves automatic subtitle selection permanently unresolved.
|
||||
///
|
||||
/// `IsExternal` rows are left alone: a stream that lives in a separate file
|
||||
/// is absent from the container whether or not this open managed to build a
|
||||
/// sidecar URL for it, so it must never fuzzy-match a native track.
|
||||
MediaSourceInfo _withSidecarBackedSubtitleIdentity(
|
||||
MediaSourceInfo mediaInfo,
|
||||
List<PlaybackSubtitleSidecar> sidecars,
|
||||
) {
|
||||
if (mediaInfo.subtitleTracks.isEmpty) return mediaInfo;
|
||||
final sidecarSourceIds = {for (final sidecar in sidecars) ?sidecar.sourceStreamId};
|
||||
return mediaInfo.copyWith(
|
||||
subtitleTracks: [
|
||||
for (final track in mediaInfo.subtitleTracks)
|
||||
track.isExternalFile || sidecarSourceIds.contains(track.id) ? track : track.withoutSidecarIdentity(),
|
||||
],
|
||||
subtitleTracks: mediaInfo.subtitleTracks,
|
||||
chapters: mediaInfo.chapters,
|
||||
partId: mediaInfo.partId,
|
||||
displayCriteria: mediaInfo.displayCriteria,
|
||||
mediaSourceId: mediaInfo.mediaSourceId,
|
||||
defaultAudioStreamIndex: mediaInfo.defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex: mediaInfo.defaultSubtitleStreamIndex,
|
||||
trickplayByWidth: mediaInfo.trickplayByWidth,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,7 +686,20 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
|
||||
},
|
||||
],
|
||||
// Embed is listed first so a direct-played container reports its
|
||||
// subtitle streams as `DeliveryMethod: Embed`, matching what the
|
||||
// native player actually reads. External stays declared for every
|
||||
// format because a remux or transcode drops those streams from the
|
||||
// rendition and the server must hand us sidecar URLs instead; the
|
||||
// server picks per play method, so both entries are required.
|
||||
'SubtitleProfiles': const <Map<String, Object?>>[
|
||||
{'Format': 'srt', 'Method': 'Embed'},
|
||||
{'Format': 'ass', 'Method': 'Embed'},
|
||||
{'Format': 'ssa', 'Method': 'Embed'},
|
||||
{'Format': 'vtt', 'Method': 'Embed'},
|
||||
{'Format': 'pgssub', 'Method': 'Embed'},
|
||||
{'Format': 'dvdsub', 'Method': 'Embed'},
|
||||
{'Format': 'dvbsub', 'Method': 'Embed'},
|
||||
{'Format': 'srt', 'Method': 'External'},
|
||||
{'Format': 'ass', 'Method': 'External'},
|
||||
{'Format': 'ssa', 'Method': 'External'},
|
||||
|
||||
@@ -67,39 +67,14 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
|
||||
|
||||
List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
|
||||
if (defaultStreamIndex == null) return tracks;
|
||||
return [
|
||||
for (final track in tracks)
|
||||
MediaAudioTrack(
|
||||
id: track.id,
|
||||
index: track.index,
|
||||
codec: track.codec,
|
||||
language: track.language,
|
||||
languageCode: track.languageCode,
|
||||
title: track.title,
|
||||
displayTitle: track.displayTitle,
|
||||
channels: track.channels,
|
||||
selected: track.index == defaultStreamIndex,
|
||||
external: track.external,
|
||||
),
|
||||
];
|
||||
return [for (final track in tracks) track.withSelected(track.index == defaultStreamIndex)];
|
||||
}
|
||||
|
||||
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
|
||||
return [
|
||||
for (final track in tracks)
|
||||
MediaSubtitleTrack(
|
||||
id: track.id,
|
||||
index: track.index,
|
||||
codec: track.codec,
|
||||
language: track.language,
|
||||
languageCode: track.languageCode,
|
||||
title: track.title,
|
||||
displayTitle: track.displayTitle,
|
||||
selected: defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
|
||||
forced: track.forced,
|
||||
key: track.key,
|
||||
external: track.external,
|
||||
usesExternalDelivery: track.usesExternalDelivery,
|
||||
track.withSelected(
|
||||
defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -205,7 +205,10 @@ class TrackManager {
|
||||
/// The five-second fallback applies any ready audio/rate settings, but a
|
||||
/// source that advertises subtitles keeps listening for their late native
|
||||
/// track-list update. The listener has a separate hard deadline and every
|
||||
/// callback is scoped to the current media generation.
|
||||
/// callback is scoped to the current media generation. The deadline pass
|
||||
/// resolves the subtitle from whatever has arrived rather than deferring
|
||||
/// again, so a source the native player never exposes ends as an explicit
|
||||
/// decision instead of silently leaving subtitles untouched.
|
||||
///
|
||||
/// Callers may arm this after an `await`, so a manager disposed or
|
||||
/// deactivated in the meantime must not subscribe or start a timer: nothing
|
||||
@@ -259,7 +262,7 @@ class TrackManager {
|
||||
if (!_tracksReadyForSelection(player.state.tracks)) {
|
||||
appLogger.w('Advertised native subtitle selection did not resolve before the 30-second deadline');
|
||||
}
|
||||
unawaited(applyTrackSelection());
|
||||
unawaited(applyTrackSelection(waitForPendingSource: false));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -284,7 +287,11 @@ class TrackManager {
|
||||
|
||||
/// Core track selection: delegates to [TrackSelectionService]. Returns
|
||||
/// whether every player mutation completed for this still-active owner.
|
||||
Future<bool> applyTrackSelection() async {
|
||||
///
|
||||
/// Pass `waitForPendingSource: false` from a deadline pass so an advertised
|
||||
/// subtitle that never materialized resolves to the best available choice
|
||||
/// instead of deferring forever.
|
||||
Future<bool> applyTrackSelection({bool waitForPendingSource = true}) async {
|
||||
final selectionGeneration = _selectionGeneration;
|
||||
bool selectionIsActive() => _isSelectionCurrent(selectionGeneration);
|
||||
if (!selectionIsActive()) return false;
|
||||
@@ -298,7 +305,7 @@ class TrackManager {
|
||||
if (activeSelectionDone == null) return false;
|
||||
await activeSelectionDone;
|
||||
if (!selectionIsActive()) return false;
|
||||
return applyTrackSelection();
|
||||
return applyTrackSelection(waitForPendingSource: waitForPendingSource);
|
||||
}
|
||||
|
||||
_isApplyingTrackSelection = true;
|
||||
@@ -328,6 +335,7 @@ class TrackManager {
|
||||
onSubtitleTrackChanged: onSubtitleTrackChanged,
|
||||
isActive: selectionIsActive,
|
||||
onPlayerMutationDispatched: _trackDispatchedPlayerMutation,
|
||||
waitForPendingSource: waitForPendingSource,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to apply track selection', error: e);
|
||||
|
||||
@@ -740,10 +740,16 @@ class TrackSelectionService {
|
||||
return sourceId == null ? null : plexMediaInfo?.subtitleTracks.where((track) => track.id == sourceId).firstOrNull;
|
||||
}
|
||||
|
||||
bool _hasCompleteDirectPlexCatalogFor(MediaSubtitleTrack? sourceTrack, List<SubtitleTrack> availableTracks) {
|
||||
/// Whether the source catalog can prove it has already delivered every
|
||||
/// ordinary direct-embedded row, so a still-unmatched [sourceTrack] is a
|
||||
/// real mismatch rather than a native track that has not arrived yet.
|
||||
///
|
||||
/// Backend-neutral: any backend whose source rows describe streams inside
|
||||
/// the container can reach completeness. Rows delivered as sidecars never
|
||||
/// can, because they arrive on their own schedule.
|
||||
bool _hasCompleteDirectSourceCatalogFor(MediaSubtitleTrack? sourceTrack, List<SubtitleTrack> availableTracks) {
|
||||
final info = plexMediaInfo;
|
||||
return metadata.backend == MediaBackend.plex &&
|
||||
info != null &&
|
||||
return info != null &&
|
||||
sourceTrack != null &&
|
||||
_isDirectEmbeddedPlexSubtitle(sourceTrack) &&
|
||||
_classifyDirectEmbeddedSubtitleCatalog(info.subtitleTracks, availableTracks) ==
|
||||
@@ -917,11 +923,18 @@ class TrackSelectionService {
|
||||
/// Returns null only while the source catalog can still deliver the requested
|
||||
/// subtitle. A complete catalog with no unambiguous match proceeds through
|
||||
/// the safe default/off priorities instead of waiting indefinitely.
|
||||
///
|
||||
/// [waitForPendingSource] disables that wait when the caller has run out of
|
||||
/// patience: every pending branch falls through to the priorities below, so
|
||||
/// the result is a real decision rather than "ask again later". A deadline
|
||||
/// pass must use it, otherwise it re-derives the same null and applies
|
||||
/// nothing at all.
|
||||
TrackSelectionResult<SubtitleTrack>? selectSubtitleTrack(
|
||||
List<SubtitleTrack> availableTracks,
|
||||
SubtitleTrack? preferredSubtitleTrack,
|
||||
AudioTrack? selectedAudioTrack,
|
||||
) {
|
||||
AudioTrack? selectedAudioTrack, {
|
||||
bool waitForPendingSource = true,
|
||||
}) {
|
||||
// Priority 1: Try preferred track from navigation
|
||||
if (preferredSubtitleTrack != null) {
|
||||
if (preferredSubtitleTrack.id == 'no') {
|
||||
@@ -932,11 +945,17 @@ class TrackSelectionService {
|
||||
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
|
||||
}
|
||||
}
|
||||
if (preferredSubtitleTrack.id.startsWith('source:') &&
|
||||
!_hasCompleteDirectPlexCatalogFor(_sourceSubtitleTrack(preferredSubtitleTrack.id), availableTracks)) {
|
||||
if (waitForPendingSource && preferredSubtitleTrack.id.startsWith('source:')) {
|
||||
// Only a row this catalog actually advertises can still show up
|
||||
// natively. An id the catalog does not carry — a stale preference from
|
||||
// another media source — resolves the same way on every retry, so
|
||||
// waiting for it would defer selection forever.
|
||||
final sourceTrack = _sourceSubtitleTrack(preferredSubtitleTrack.id);
|
||||
if (sourceTrack != null && !_hasCompleteDirectSourceCatalogFor(sourceTrack, availableTracks)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Trust the server's selected track. Plex computes this from
|
||||
// account/show/per-item prefs; Jellyfin exposes DefaultSubtitleStreamIndex.
|
||||
@@ -954,8 +973,11 @@ class TrackSelectionService {
|
||||
if (matchedMpvTrack != null) {
|
||||
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected);
|
||||
}
|
||||
if (metadata.backend == MediaBackend.plex &&
|
||||
!_hasCompleteDirectPlexCatalogFor(serverSelectedTrack, availableTracks)) {
|
||||
// A server-selected row the native player has not produced yet must
|
||||
// keep the pass pending on every backend. Falling through here would
|
||||
// commit an unrelated native default and, because readiness is this
|
||||
// same decision, retire the listener before the real track lands.
|
||||
if (waitForPendingSource && !_hasCompleteDirectSourceCatalogFor(serverSelectedTrack, availableTracks)) {
|
||||
return null;
|
||||
}
|
||||
} else if (metadata.backend == MediaBackend.jellyfin) {
|
||||
@@ -982,11 +1004,11 @@ class TrackSelectionService {
|
||||
}
|
||||
}
|
||||
} else if (metadata.backend == MediaBackend.plex && info.subtitleTracks.isNotEmpty) {
|
||||
if (availableTracks.isEmpty) return null;
|
||||
if (availableTracks.isEmpty && waitForPendingSource) return null;
|
||||
// Native tracks exist and none maps to a server-selected stream.
|
||||
return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected);
|
||||
}
|
||||
if (availableTracks.isEmpty && info.subtitleTracks.isNotEmpty) return null;
|
||||
if (waitForPendingSource && availableTracks.isEmpty && info.subtitleTracks.isNotEmpty) return null;
|
||||
}
|
||||
|
||||
// Priority 3: Apply server profile subtitle mode when the backend exposes
|
||||
@@ -1014,6 +1036,7 @@ class TrackSelectionService {
|
||||
Function(SubtitleTrack)? onSubtitleTrackChanged,
|
||||
bool Function()? isActive,
|
||||
void Function(Future<void> mutation)? onPlayerMutationDispatched,
|
||||
bool waitForPendingSource = true,
|
||||
}) async {
|
||||
final player = this.player;
|
||||
if (player == null) {
|
||||
@@ -1063,7 +1086,12 @@ class TrackSelectionService {
|
||||
|
||||
// Select and apply subtitle track. A null result means source metadata
|
||||
// advertises subtitles that the native player has not exposed yet.
|
||||
final subtitleResult = selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack);
|
||||
final subtitleResult = selectSubtitleTrack(
|
||||
realSubtitleTracks,
|
||||
preferredSubtitleTrack,
|
||||
selectedAudioTrack,
|
||||
waitForPendingSource: waitForPendingSource,
|
||||
);
|
||||
if (subtitleResult != null) {
|
||||
final selectedSubtitleTrack = subtitleResult.track;
|
||||
final subtitleName = selectedSubtitleTrack.id == 'no'
|
||||
|
||||
@@ -725,6 +725,114 @@ void main() {
|
||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization strips sidecar identity from direct-played embedded subtitles', () async {
|
||||
// Jellyfin answers `Method: External` subtitle profiles with an
|
||||
// `External` delivery method plus a DeliveryUrl even for streams that
|
||||
// stay inside a direct-played container. Direct play never fetches those
|
||||
// URLs, so the rows must not keep an identity that makes track matching
|
||||
// wait for a sidecar (issue #1696).
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||
return jsonResponse({
|
||||
'Id': 'item-1',
|
||||
'Type': 'Movie',
|
||||
'Name': 'Movie',
|
||||
'MediaSources': [
|
||||
{'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||
return jsonResponse({
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'src-1',
|
||||
'Container': 'mkv',
|
||||
'SupportsDirectPlay': true,
|
||||
'DefaultSubtitleStreamIndex': 3,
|
||||
'MediaStreams': [
|
||||
{'Index': 1, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'IsDefault': true},
|
||||
{
|
||||
'Index': 3,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'ass',
|
||||
'Language': 'eng',
|
||||
'DisplayTitle': 'English Forced - ASS',
|
||||
'IsDefault': true,
|
||||
'IsForced': true,
|
||||
'IsExternal': false,
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/0/Stream.ass',
|
||||
},
|
||||
{
|
||||
'Index': 4,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'ass',
|
||||
'Language': 'eng',
|
||||
'DisplayTitle': 'English - ASS',
|
||||
'IsExternal': false,
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/0/Stream.ass',
|
||||
},
|
||||
{
|
||||
'Index': 5,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'srt',
|
||||
'Language': 'swe',
|
||||
'DisplayTitle': 'Swedish - SRT',
|
||||
'IsExternal': true,
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return http.Response('{}', 404);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final result = await scoped.getPlaybackInitialization(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'item-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.movie,
|
||||
serverId: 'srv-1',
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.isTranscoding, isFalse);
|
||||
expect(result.playMethod, 'DirectPlay');
|
||||
|
||||
final tracks = result.mediaInfo!.subtitleTracks;
|
||||
expect(tracks.map((track) => track.id), [3, 4, 5]);
|
||||
|
||||
// Embedded rows lose the delivery hint they cannot honour.
|
||||
for (final track in tracks.where((track) => track.id != 5)) {
|
||||
expect(track.key, isNull, reason: 'embedded row ${track.id} kept a delivery URL');
|
||||
expect(track.usesExternalDelivery, isFalse);
|
||||
expect(track.isExternal, isFalse);
|
||||
}
|
||||
|
||||
// A genuine separate file is absent from the container either way, and
|
||||
// direct play does load it, so it keeps its sidecar identity.
|
||||
final sidecarRow = tracks.singleWhere((track) => track.id == 5);
|
||||
expect(sidecarRow.key, '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt');
|
||||
expect(sidecarRow.isExternalFile, isTrue);
|
||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [5]);
|
||||
|
||||
// The server default survives normalization so selection can honour it.
|
||||
expect(result.mediaInfo!.defaultSubtitleStreamIndex, 3);
|
||||
expect(tracks.singleWhere((track) => track.id == 3).selected, isTrue);
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
@@ -1578,7 +1686,7 @@ void main() {
|
||||
expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo'));
|
||||
});
|
||||
|
||||
test('getPlaybackInfo advertises external subtitle support', () async {
|
||||
test('getPlaybackInfo advertises embedded and external subtitle delivery', () async {
|
||||
Uri? capturedUri;
|
||||
String? capturedBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -1617,12 +1725,25 @@ void main() {
|
||||
expect(directPlayProfile['AudioCodec'], contains('mp2'));
|
||||
expect(profile['TranscodingProfiles'], isNotEmpty);
|
||||
expect(profile['CodecProfiles'], isEmpty);
|
||||
final subtitleProfiles = profile['SubtitleProfiles'] as List<dynamic>;
|
||||
const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub'];
|
||||
final subtitleProfiles = [
|
||||
for (final entry in profile['SubtitleProfiles'] as List<dynamic>) entry as Map<String, dynamic>,
|
||||
];
|
||||
// Every format is offered both ways, Embed first: the server picks per
|
||||
// play method, so direct play reports its container streams as embedded
|
||||
// while a remux or transcode still hands back sidecar URLs.
|
||||
expect(
|
||||
subtitleProfiles.map((profile) => (profile as Map<String, dynamic>)['Format']),
|
||||
containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']),
|
||||
subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']),
|
||||
subtitleFormats,
|
||||
);
|
||||
expect(
|
||||
subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
|
||||
subtitleFormats,
|
||||
);
|
||||
expect(
|
||||
subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'),
|
||||
lessThan(subtitleProfiles.indexWhere((entry) => entry['Method'] == 'External')),
|
||||
);
|
||||
expect(subtitleProfiles.every((profile) => (profile as Map<String, dynamic>)['Method'] == 'External'), isTrue);
|
||||
});
|
||||
|
||||
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
|
||||
|
||||
@@ -16,6 +16,7 @@ MediaSubtitleTrack _sourceSubtitle(
|
||||
bool selected = false,
|
||||
bool external = false,
|
||||
bool usesExternalDelivery = false,
|
||||
String? key,
|
||||
}) {
|
||||
return MediaSubtitleTrack(
|
||||
id: id,
|
||||
@@ -25,6 +26,9 @@ MediaSubtitleTrack _sourceSubtitle(
|
||||
title: 'Subtitle $id',
|
||||
selected: selected,
|
||||
forced: forced,
|
||||
// Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL
|
||||
// with every row it marks external, and _sidecar's URL contains it.
|
||||
key: key ?? (external || usesExternalDelivery ? '/subtitles/$id.srt' : null),
|
||||
external: external,
|
||||
usesExternalDelivery: usesExternalDelivery,
|
||||
);
|
||||
@@ -130,8 +134,10 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('fuzzy-matches Jellyfin external-delivery rows that remain embedded in direct play', () {
|
||||
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
|
||||
test('fuzzy-matches a Jellyfin external-delivery row once direct play strips its sidecar identity', () {
|
||||
// JellyfinClient.getPlaybackInitialization normalizes rows it did not
|
||||
// fetch as sidecars, which is what makes the embedded stream reachable.
|
||||
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true).withoutSidecarIdentity();
|
||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||
|
||||
expect(
|
||||
@@ -145,6 +151,23 @@ void main() {
|
||||
native,
|
||||
);
|
||||
});
|
||||
|
||||
test('a row that kept its sidecar identity never fuzzy-matches a native track', () {
|
||||
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
|
||||
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
|
||||
|
||||
expect(
|
||||
PlaybackSubtitleResolver.nativeTrackForSource(
|
||||
sourceTrack: source,
|
||||
nativeTracks: const [native],
|
||||
allSourceTracks: [source],
|
||||
isResolvedSidecar: false,
|
||||
isContainerSidecar: false,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches a requested source among tracks from one container sidecar', () {
|
||||
final sources = [_sourceSubtitle(2, language: 'eng'), _sourceSubtitle(3, language: 'eng')];
|
||||
const nativeTracks = [
|
||||
|
||||
@@ -990,6 +990,54 @@ void main() {
|
||||
mgr.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('thirty-second deadline resolves a subtitle the source never delivered', () async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
fakeAsync((async) {
|
||||
// A keyed sidecar that never attaches: the catalog can never prove it
|
||||
// is complete, so selection defers until the deadline gives up on it.
|
||||
final mediaInfo = MediaSourceInfo(
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
|
||||
subtitleTracks: [
|
||||
MediaSubtitleTrack(
|
||||
id: 10,
|
||||
languageCode: 'eng',
|
||||
codec: 'srt',
|
||||
selected: true,
|
||||
forced: false,
|
||||
key: '/library/streams/10',
|
||||
external: true,
|
||||
),
|
||||
],
|
||||
chapters: const [],
|
||||
);
|
||||
final player = _FakePlayer(
|
||||
tracks: const Tracks(
|
||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||
subtitle: [SubtitleTrack(id: '10', language: 'eng', codec: 'srt', isDefault: true)],
|
||||
),
|
||||
);
|
||||
final mgr = _make(player: player, mediaInfo: mediaInfo);
|
||||
|
||||
mgr.applyTrackSelectionWhenReady();
|
||||
async.elapse(const Duration(seconds: 5));
|
||||
async.flushMicrotasks();
|
||||
|
||||
// The five-second pass applies ready audio and keeps waiting.
|
||||
expect(player.selectedAudio, hasLength(1));
|
||||
expect(player.selectedSubtitle, isEmpty);
|
||||
|
||||
async.elapse(const Duration(seconds: 25));
|
||||
async.flushMicrotasks();
|
||||
|
||||
// The deadline must decide rather than defer a third time.
|
||||
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
|
||||
expect(async.nonPeriodicTimerCount, 0);
|
||||
mgr.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -701,6 +701,142 @@ void main() {
|
||||
expect(result.track.language, 'fre');
|
||||
});
|
||||
|
||||
group('Jellyfin direct play (issue #1696)', () {
|
||||
// JellyfinClient strips sidecar identity from rows it did not fetch, so
|
||||
// a direct-played embedded stream reaches selection as a plain row.
|
||||
MediaSourceInfo directPlayInfo() => _info(
|
||||
defaultSubtitleStreamIndex: 3,
|
||||
subs: [
|
||||
_plexSub(
|
||||
3,
|
||||
index: 3,
|
||||
languageCode: 'eng',
|
||||
title: 'English Forced',
|
||||
codec: 'ass',
|
||||
selected: true,
|
||||
forced: true,
|
||||
),
|
||||
_plexSub(4, index: 4, languageCode: 'eng', title: 'English', codec: 'ass'),
|
||||
],
|
||||
);
|
||||
|
||||
final nativeTracks = [
|
||||
_sub('1', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true),
|
||||
_sub('2', lang: 'eng', title: 'English', codec: 'ass'),
|
||||
];
|
||||
|
||||
test('applies the server default instead of waiting for a sidecar', () {
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: directPlayInfo(),
|
||||
).selectSubtitleTrack(nativeTracks, null, null);
|
||||
|
||||
expect(result?.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(result?.track.id, '1');
|
||||
});
|
||||
|
||||
test('resolves a source preference carried over from the open', () {
|
||||
final result =
|
||||
_svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: directPlayInfo(),
|
||||
).selectSubtitleTrack(
|
||||
nativeTracks,
|
||||
_sub('source:3', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true, isDefault: true),
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result?.priority, TrackSelectionPriority.navigation);
|
||||
expect(result?.track.id, '1');
|
||||
});
|
||||
|
||||
test('an unmatched source preference stops waiting once the catalog is complete', () {
|
||||
// Both source rows are present natively, so nothing more can arrive:
|
||||
// the unresolvable preference must fall through, not defer forever.
|
||||
final result = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: directPlayInfo(),
|
||||
).selectSubtitleTrack(nativeTracks, _sub('source:9', lang: 'kor', codec: 'srt'), null);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.track.id, '1');
|
||||
});
|
||||
|
||||
test('waits for a server-selected sidecar even when another native track has arrived', () {
|
||||
// Transcode: the selected row is delivered as a sidecar and has not
|
||||
// attached yet, while a different sidecar already has. Committing that
|
||||
// unrelated track would also mark the pass ready and retire the
|
||||
// listener, so the real selection could never land.
|
||||
final info = _info(
|
||||
defaultSubtitleStreamIndex: 3,
|
||||
subs: [
|
||||
_plexSub(3, index: 3, languageCode: 'eng', codec: 'srt', key: '/Subtitles/3', selected: true),
|
||||
_plexSub(4, index: 4, languageCode: 'swe', codec: 'srt', key: '/Subtitles/4'),
|
||||
],
|
||||
);
|
||||
final arrivedTracks = [_sub('sw', lang: 'swe', codec: 'srt', isDefault: true, isExternal: true)];
|
||||
final service = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
);
|
||||
|
||||
expect(service.selectSubtitleTrack(arrivedTracks, null, null), isNull);
|
||||
|
||||
// Once the selected sidecar attaches, its keyed identity resolves.
|
||||
final selectedNative = SubtitleTrack(
|
||||
id: 'en',
|
||||
language: 'eng',
|
||||
codec: 'srt',
|
||||
isExternal: true,
|
||||
uri: 'https://jf.example.com/Subtitles/3?api_key=tok',
|
||||
);
|
||||
final resolved = service.selectSubtitleTrack([...arrivedTracks, selectedNative], null, null);
|
||||
expect(resolved?.priority, TrackSelectionPriority.serverSelected);
|
||||
expect(resolved?.track.id, 'en');
|
||||
});
|
||||
});
|
||||
|
||||
group('deadline resolution', () {
|
||||
test('waitForPendingSource: false resolves a source that never arrived', () {
|
||||
// A sidecar-delivered catalog can never prove completeness, so this
|
||||
// stays pending until the caller gives up on it.
|
||||
final info = _info(
|
||||
subs: [
|
||||
_plexSub(3, index: 3, languageCode: 'eng', codec: 'srt', key: '/Subtitles/3', selected: true),
|
||||
_plexSub(4, index: 4, languageCode: 'swe', codec: 'srt', key: '/Subtitles/4'),
|
||||
],
|
||||
);
|
||||
final nativeTracks = [_sub('1', lang: 'eng', codec: 'srt', isDefault: true)];
|
||||
final service = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
);
|
||||
final preferred = _sub('source:3', lang: 'eng', codec: 'srt');
|
||||
|
||||
expect(service.selectSubtitleTrack(nativeTracks, preferred, null), isNull);
|
||||
|
||||
final resolved = service.selectSubtitleTrack(nativeTracks, preferred, null, waitForPendingSource: false);
|
||||
expect(resolved?.priority, TrackSelectionPriority.defaultTrack);
|
||||
expect(resolved?.track.id, '1');
|
||||
});
|
||||
|
||||
test('waitForPendingSource: false turns an empty native catalog into an explicit off', () {
|
||||
final info = _info(
|
||||
subs: [_plexSub(3, index: 3, languageCode: 'eng', codec: 'srt', key: '/Subtitles/3')],
|
||||
);
|
||||
final service = _svc(
|
||||
metadata: _meta(backend: MediaBackend.jellyfin),
|
||||
info: info,
|
||||
);
|
||||
|
||||
expect(service.selectSubtitleTrack(const [], null, null), isNull);
|
||||
expect(
|
||||
service.selectSubtitleTrack(const [], null, null, waitForPendingSource: false)?.track.id,
|
||||
SubtitleTrack.off.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Jellyfin explicit DefaultSubtitleStreamIndex=-1 forces subtitles off', () {
|
||||
final tracks = [_sub('1', lang: 'eng', isDefault: true), _sub('2', lang: 'fre')];
|
||||
final info = _info(
|
||||
|
||||
Reference in New Issue
Block a user