From 4095cc27e1e2ad774bcc7ea38307b391f979b6a5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:43:42 +0100 Subject: [PATCH] perf: reduce redundant metadata API requests --- lib/providers/download_provider.dart | 17 +++--- lib/screens/media_detail_screen.dart | 5 ++ lib/screens/metadata_edit_screen.dart | 10 ++++ lib/screens/video_player_screen.dart | 4 ++ .../playback_initialization_service.dart | 27 ++++++---- lib/services/plex_client.dart | 53 +++++++++++-------- lib/utils/video_player_navigation.dart | 11 ++++ 7 files changed, 88 insertions(+), 39 deletions(-) diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 1a8134f3..7a4185c2 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -657,15 +657,18 @@ class DownloadProvider extends ChangeNotifier { } // Fetch full metadata to get year, summary, clearLogo - // The metadata from getChildren() is summarized and missing these fields + // The metadata from getChildren() is summarized and missing these fields. + // If metadata already has summary, it's already full (e.g., from detail screen). PlexMetadata metadataToStore = metadata; - try { - final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey); - if (fullMetadata != null) { - metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName); + if (metadata.summary == null) { + try { + final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey); + if (fullMetadata != null) { + metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName); + } + } catch (e) { + appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e); } - } catch (e) { - appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e); } // For episodes, also fetch and store show and season metadata for offline display diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0423845f..cc1f2109 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -24,6 +24,7 @@ import '../utils/plex_image_helper.dart'; import '../../services/plex_client.dart'; import '../services/plex_api_cache.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_video_playback_data.dart'; import '../utils/content_utils.dart'; import '../utils/rating_utils.dart'; import '../models/download_models.dart'; @@ -65,6 +66,7 @@ class _MediaDetailScreenState extends State with WatchStateAw bool _isLoadingSeasons = false; PlexMetadata? _fullMetadata; PlexMetadata? _onDeckEpisode; + PlexVideoPlaybackData? _playbackData; bool _isLoadingMetadata = true; List? _extras; late final ScrollController _scrollController; @@ -360,6 +362,7 @@ class _MediaDetailScreenState extends State with WatchStateAw metadata: metadata, isOffline: widget.isOffline, onRefresh: _loadFullMetadata, + playbackData: _playbackData, ); } } @@ -966,6 +969,7 @@ class _MediaDetailScreenState extends State with WatchStateAw final result = await client.getMetadataWithImagesAndOnDeck(widget.metadata.ratingKey); final metadata = result['metadata'] as PlexMetadata?; final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; + final playbackData = result['playbackData'] as PlexVideoPlaybackData?; if (!mounted) return; @@ -983,6 +987,7 @@ class _MediaDetailScreenState extends State with WatchStateAw setState(() { _fullMetadata = metadataWithServerId; _onDeckEpisode = onDeckWithServerId; + _playbackData = playbackData; _isLoadingMetadata = false; }); diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index d17fb0e8..9b43e9bd 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -73,6 +73,16 @@ class _MetadataEditScreenState extends State { Future _loadMetadata() async { try { + // If the passed metadata already has full fields (e.g., from detail screen), + // use it directly instead of re-fetching. We check both summary and + // librarySectionID since the edit screen needs both for display and save. + if (widget.metadata.summary != null && widget.metadata.librarySectionID != null) { + _fullMetadata = widget.metadata; + _initFieldsFromMetadata(widget.metadata); + setState(() => _isLoading = false); + return; + } + final meta = await _client.getMetadataWithImages(widget.metadata.ratingKey); if (!mounted) return; if (meta != null) { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9b31f97e..1da91ae7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -18,6 +18,7 @@ import '../models/livetv_channel.dart'; import '../services/plex_api_cache.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_video_playback_data.dart'; import '../utils/content_utils.dart'; import '../models/plex_media_info.dart'; import '../providers/download_provider.dart'; @@ -71,6 +72,7 @@ class VideoPlayerScreen extends StatefulWidget { final SubtitleTrack? preferredSubtitleTrack; final int selectedMediaIndex; final bool isOffline; + final PlexVideoPlaybackData? playbackData; // Live TV fields final bool isLive; @@ -90,6 +92,7 @@ class VideoPlayerScreen extends StatefulWidget { this.preferredSubtitleTrack, this.selectedMediaIndex = 0, this.isOffline = false, + this.playbackData, this.isLive = false, this.liveChannelName, this.liveStreamUrl, @@ -930,6 +933,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin metadata: widget.metadata, selectedMediaIndex: widget.selectedMediaIndex, preferOffline: true, // Use downloaded file if available + playbackData: widget.playbackData, ); } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 6256f353..8de2e92e 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -1,6 +1,7 @@ import 'plex_client.dart'; import '../models/plex_media_info.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_video_playback_data.dart'; import '../models/download_models.dart'; import '../mpv/mpv.dart'; import '../utils/app_logger.dart'; @@ -75,10 +76,12 @@ class PlaybackInitializationService { /// /// Returns a PlaybackInitializationResult with video URL and available versions /// If [preferOffline] is true and offline content is available, uses local file + /// If [playbackData] is provided, skips the network call to fetch it again. Future getPlaybackData({ required PlexMetadata metadata, required int selectedMediaIndex, bool preferOffline = false, + PlexVideoPlaybackData? playbackData, }) async { try { // Check for offline content first if preferOffline is enabled @@ -94,16 +97,17 @@ class PlaybackInitializationService { // For offline playback, we still need to fetch media info for subtitles // but use the local file path for video try { - final playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); + final data = + playbackData ?? await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); // Build list of external subtitle tracks - final externalSubtitles = _buildExternalSubtitles(playbackData.mediaInfo); + final externalSubtitles = _buildExternalSubtitles(data.mediaInfo); // Return result with local file path return PlaybackInitializationResult( - availableVersions: playbackData.availableVersions, + availableVersions: data.availableVersions, videoUrl: _formatVideoUrl(offlineVideoPath), - mediaInfo: playbackData.mediaInfo, + mediaInfo: data.mediaInfo, externalSubtitles: externalSubtitles, isOffline: true, ); @@ -120,21 +124,22 @@ class PlaybackInitializationService { } } - // Fall back to network streaming - final playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); + // Use pre-parsed data or fall back to network streaming + final data = + playbackData ?? await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); - if (!playbackData.hasValidVideoUrl) { + if (!data.hasValidVideoUrl) { throw PlaybackException(t.messages.fileInfoNotAvailable); } // Build list of external subtitle tracks - final externalSubtitles = _buildExternalSubtitles(playbackData.mediaInfo); + final externalSubtitles = _buildExternalSubtitles(data.mediaInfo); // Return result with available versions and video URL return PlaybackInitializationResult( - availableVersions: playbackData.availableVersions, - videoUrl: playbackData.videoUrl, - mediaInfo: playbackData.mediaInfo, + availableVersions: data.availableVersions, + videoUrl: data.videoUrl, + mediaInfo: data.mediaInfo, externalSubtitles: externalSubtitles, isOffline: false, ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3effd15a..152f3482 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -460,7 +460,9 @@ class PlexClient { ), parseCache: (cachedData) { final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData); - return {'metadata': metadata, 'onDeckEpisode': null}; + final firstMetadata = PlexCacheParser.extractFirstMetadata(cachedData); + final playbackData = parseVideoPlaybackDataFromJson(firstMetadata); + return {'metadata': metadata, 'onDeckEpisode': null, 'playbackData': playbackData}; }, parseResponse: (response) { PlexMetadata? metadata; @@ -485,10 +487,13 @@ class PlexClient { } } - return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; + // Parse playback data from the same response — zero extra network cost + final playbackData = parseVideoPlaybackDataFromJson(metadataJson); + + return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode, 'playbackData': playbackData}; }, ) ?? - {'metadata': null, 'onDeckEpisode': null}; + {'metadata': null, 'onDeckEpisode': null, 'playbackData': null}; } /// Get metadata by rating key with images (includes clearLogo) @@ -934,24 +939,10 @@ class PlexClient { ); } - /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call. - /// This is the primary method for playback initialization. - /// Uses cache for offline mode support and network fallback. - Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { - Map? data; - try { - data = await _fetchWithCacheFirst>( - cacheKey: '/library/metadata/$ratingKey', - networkCall: () => - _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), - parseCache: (cached) => cached as Map?, - parseResponse: (response) => response.data as Map?, - ); - } catch (_) { - // Gracefully degrade: return empty playback data on total failure - } - final metadataJson = _getFirstMetadataJsonFromData(data); - + /// Parse video playback data from raw metadata JSON (no network call). + /// Used by [getVideoPlaybackData] and [getMetadataWithImagesAndOnDeck] to + /// avoid redundant fetches when the response is already available. + PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map? metadataJson, {int mediaIndex = 0}) { String? videoUrl; PlexMediaInfo? mediaInfo; List availableVersions = []; @@ -1004,6 +995,26 @@ class PlexClient { ); } + /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call. + /// This is the primary method for playback initialization. + /// Uses cache for offline mode support and network fallback. + Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { + Map? data; + try { + data = await _fetchWithCacheFirst>( + cacheKey: '/library/metadata/$ratingKey', + networkCall: () => + _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, + ); + } catch (_) { + // Gracefully degrade: return empty playback data on total failure + } + final metadataJson = _getFirstMetadataJsonFromData(data); + return parseVideoPlaybackDataFromJson(metadataJson, mediaIndex: mediaIndex); + } + /// Get file information for a media item /// Uses cache for offline mode support and network fallback. Future getFileInfo(String ratingKey) async { diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 2d343566..a4da4fe3 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../mpv/mpv.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_video_playback_data.dart'; import '../providers/download_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; @@ -40,12 +41,14 @@ Future navigateToVideoPlayer( int? selectedMediaIndex, bool usePushReplacement = false, bool isOffline = false, + PlexVideoPlaybackData? playbackData, }) async { // Extract navigator before any async operations final navigator = Navigator.of(context); // Load saved media version preference if not explicitly provided int mediaIndex = selectedMediaIndex ?? 0; + var effectivePlaybackData = playbackData; if (selectedMediaIndex == null) { try { final settingsService = await SettingsService.getInstance(); @@ -53,6 +56,11 @@ Future navigateToVideoPlayer( final savedPreference = settingsService.getMediaVersionPreference(seriesKey); if (savedPreference != null) { mediaIndex = savedPreference; + // Pre-parsed playbackData was built with mediaIndex=0; invalidate if + // the resolved index differs so the player re-fetches with the correct one + if (savedPreference != 0) { + effectivePlaybackData = null; + } } } catch (e) { // Ignore errors loading preference, use default @@ -109,6 +117,7 @@ Future navigateToVideoPlayer( preferredSubtitleTrack: preferredSubtitleTrack, selectedMediaIndex: mediaIndex, isOffline: isOffline, + playbackData: effectivePlaybackData, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, @@ -140,6 +149,7 @@ Future navigateToVideoPlayerWithRefresh( SubtitleTrack? preferredSubtitleTrack, int? selectedMediaIndex, bool usePushReplacement = false, + PlexVideoPlaybackData? playbackData, }) async { final result = await navigateToVideoPlayer( context, @@ -149,6 +159,7 @@ Future navigateToVideoPlayerWithRefresh( preferredSubtitleTrack: preferredSubtitleTrack, selectedMediaIndex: selectedMediaIndex, usePushReplacement: usePushReplacement, + playbackData: playbackData, ); appLogger.d('Returned from playback, refreshing metadata');