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:
edde746
2026-07-28 15:22:23 +02:00
parent 19542e57f4
commit 126f5e3aa6
9 changed files with 499 additions and 76 deletions
@@ -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'},
+3 -28
View File
@@ -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,
),
];
}
+12 -4
View File
@@ -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);
+41 -13
View File
@@ -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,9 +945,15 @@ class TrackSelectionService {
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
}
}
if (preferredSubtitleTrack.id.startsWith('source:') &&
!_hasCompleteDirectPlexCatalogFor(_sourceSubtitleTrack(preferredSubtitleTrack.id), availableTracks)) {
return null;
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;
}
}
}
@@ -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'