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.