From 8b5ee4f3dd389580fc09ff6619294bd1a025ebf3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:52:13 +0200 Subject: [PATCH] fix(subtitles): apply downloaded subtitles instead of polling the stale metadata cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-player subtitle search always ended with "Subtitle downloaded, but it could not be selected": the apply flow polls getVideoPlaybackData for the new external stream, but the fresh-cache-first fast path (be9197ed) served the pre-download /library/metadata row for every iteration — playback start leaves a fresh row, and the first network poll re-stamps it, so the new stream was never observed and the flow timed out. Add forceRefresh to PlexClient.getVideoPlaybackData (mirroring getPlaybackExtras) and pass it from the download poll loop. The forced fetch still rewrites the cache row, so the subsequent source switch sees the new track. --- lib/services/plex_client.dart | 43 +++++++++++-------- .../video_controls/parts/navigation.dart | 5 ++- .../plex_playback_data_request_test.dart | 15 +++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index c15039826..8263834ca 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1922,11 +1922,18 @@ class PlexClient /// Get consolidated video playback data in one cache-aware API call. /// Request/decode failures throw. Only a valid absent metadata/media/part /// shape returns an aggregate without a playable URL. + /// + /// [forceRefresh] skips the fresh-cache fast path so server-side stream + /// changes become observable. Required by pollers (the OpenSubtitles + /// download flow watches for a new external stream to appear): without it, + /// each successful network fetch re-stamps the shared cache row, so every + /// later poll inside the freshness window is served the stale snapshot. Future getVideoPlaybackData( String ratingKey, { int mediaIndex = 0, String? selectedMediaSourceId, String? preferredVersionSignature, + bool forceRefresh = false, }) async { // Fresh-cache-first: the detail screen writes a strict superset of this // query shape (includeChapters+includeMarkers+includeOnDeck+checkFiles+ @@ -1936,24 +1943,26 @@ class PlexClient // staleness, thin row (getPlaybackExtras' lean fetch overwrites the shared // row without includeStreams/checkFiles), or shape failure falls through // to the network-first fetch below unchanged. - final freshRow = await cache.getIfFresh( - ServerId(cacheServerId), - '/library/metadata/$ratingKey', - maxAge: playbackMetadataCacheFreshness, - ); - if (freshRow != null) { - try { - final cachedMetadataJson = _validatedPlaybackMetadataJson(freshRow); - if (cachedMetadataJson != null && _plexMetadataHasStreamDetail(cachedMetadataJson)) { - return parseVideoPlaybackDataFromJson( - cachedMetadataJson, - mediaIndex: mediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - ); + if (!forceRefresh) { + final freshRow = await cache.getIfFresh( + ServerId(cacheServerId), + '/library/metadata/$ratingKey', + maxAge: playbackMetadataCacheFreshness, + ); + if (freshRow != null) { + try { + final cachedMetadataJson = _validatedPlaybackMetadataJson(freshRow); + if (cachedMetadataJson != null && _plexMetadataHasStreamDetail(cachedMetadataJson)) { + return parseVideoPlaybackDataFromJson( + cachedMetadataJson, + mediaIndex: mediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, + ); + } + } on FormatException { + // Malformed cached row: the network fetch below overwrites it. } - } on FormatException { - // Malformed cached row: the network fetch below overwrites it. } } final data = await fetchWithCacheFallback>( diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 64e85b6cc..fba61bab6 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -117,7 +117,10 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { try { if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded; - final data = await client.getVideoPlaybackData(ratingKey); + // forceRefresh: playback start left a fresh /library/metadata row in + // the cache (and each network poll would re-stamp it), so a + // cache-eligible read here would never observe the new stream. + final data = await client.getVideoPlaybackData(ratingKey, forceRefresh: true); if (!targetIsCurrent()) return SubtitleDownloadApplyOutcome.superseded; if (data.mediaInfo == null) continue; diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 79cc47330..964f145d0 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -318,6 +318,21 @@ void main() { expect(data.mediaInfo?.subtitleTracks.single.id, 401); }); + test('forceRefresh bypasses a fresh stream-rich cached row', () async { + // The subtitle-download poller relies on this: it must observe the new + // external stream appearing server-side while the shared row is fresh. + await PlexApiCache.instance.put(cacheScope, endpoint, richPlaybackPayload()); + final requests = []; + final client = makeCountingClient(requests); + addTearDown(client.close); + + final data = await client.getVideoPlaybackData('42', forceRefresh: true); + + expect(requests, hasLength(1)); + expect(requests.single.queryParameters['includeStreams'], '1'); + expect(data.mediaInfo?.subtitleTracks.single.id, 401); + }); + test('fresh but stream-less cached row still fetches from the network', () async { // getPlaybackExtras' lean fetch overwrites the shared row without // includeStreams/checkFiles; that shape must never satisfy playback.