From 126f5e3aa6fe2e25bc68287ecc0628b7bd857b22 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:55:36 +0200 Subject: [PATCH] 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 --- lib/media/media_source_info.dart | 65 +++++++++ .../jellyfin_client/parts/playback.dart | 67 +++++---- lib/services/jellyfin_media_info_mapper.dart | 31 +--- lib/services/track_manager.dart | 16 ++- lib/services/track_selection_service.dart | 54 +++++-- test/services/jellyfin_client_urls_test.dart | 131 ++++++++++++++++- .../playback_subtitle_resolver_test.dart | 27 +++- test/services/track_manager_test.dart | 48 +++++++ .../track_selection_service_test.dart | 136 ++++++++++++++++++ 9 files changed, 499 insertions(+), 76 deletions(-) diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 79f3f88a..9898d22c 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -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? audioTracks, List? 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 { diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 060ae2b9..68775467 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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 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 >[ + {'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'}, diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 3732b502..6cc981cc 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -67,39 +67,14 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( List _withDefaultAudioSelection(List 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 _withDefaultSubtitleSelection(List 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, ), ]; } diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index 8fcc3d8f..fd1c3d93 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -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 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 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); diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 130bc0b6..80aa5298 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -740,10 +740,16 @@ class TrackSelectionService { return sourceId == null ? null : plexMediaInfo?.subtitleTracks.where((track) => track.id == sourceId).firstOrNull; } - bool _hasCompleteDirectPlexCatalogFor(MediaSubtitleTrack? sourceTrack, List 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 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? selectSubtitleTrack( List 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 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' diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index dc854143..c3ea21a8 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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; + const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']; + final subtitleProfiles = [ + for (final entry in profile['SubtitleProfiles'] as List) entry as Map, + ]; + // 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)['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)['Method'] == 'External'), isTrue); }); test('path-encodes reserved ids for browse and watch-state endpoints', () async { diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index 72afe30e..c58900db 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -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 = [ diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index d8902f56..b0b64bb5 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -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(); + }); + }); }); // ============================================================ diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index ae6b50b9..39d7cfdc 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -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(