From 59b3e6ce81c63a8ff9f4e9d30229612bc192d5af Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:57:11 +0100 Subject: [PATCH] fix: eliminate duplicate API requests on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip redundant notifyListeners in HiddenLibrariesProvider init when set is empty (empty→empty is a no-op for consumers) - Track last-seen hidden keys in DiscoverScreen to avoid reloading content when the listener fires without an actual change - Track previous online server set in MultiServerProvider; only re-check DVR availability when a new server comes online - Remove dead content-loading pipeline from libraries_screen (sorts, pagination, filters, items) that was never rendered - Move /media/providers fetch into PlexClient.create() init, replacing lazy EPG cache and providing libraries including shared items --- lib/providers/hidden_libraries_provider.dart | 6 +- lib/providers/multi_server_provider.dart | 22 ++- lib/screens/discover_screen.dart | 15 ++ lib/screens/libraries/libraries_screen.dart | 167 +----------------- lib/screens/livetv/live_tv_screen.dart | 19 +-- lib/services/multi_server_manager.dart | 2 +- lib/services/plex_client.dart | 171 +++++++++++++------ 7 files changed, 167 insertions(+), 235 deletions(-) diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index f6f6bdb6..54cd214a 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -31,7 +31,11 @@ class HiddenLibrariesProvider extends ChangeNotifier { _storageService = await StorageService.getInstance(); _hiddenLibraryKeys = _storageService.getHiddenLibraries(); _isInitialized = true; - notifyListeners(); + // Only notify if there are actually hidden libraries — the default empty + // set is already visible to consumers, so empty→empty is a no-op. + if (_hiddenLibraryKeys.isNotEmpty) { + notifyListeners(); + } } /// Hide a library by its key diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index dd83ac49..b5ff5430 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/livetv_dvr.dart'; import '../services/plex_client.dart'; import '../services/data_aggregation_service.dart'; import '../services/multi_server_manager.dart'; @@ -14,7 +15,10 @@ class LiveTvServerInfo { final String dvrKey; final String? lineup; - LiveTvServerInfo({required this.serverId, required this.dvrKey, this.lineup}); + /// Full DVR objects including channel mappings (avoids re-fetching in LiveTvScreen) + final List dvrs; + + LiveTvServerInfo({required this.serverId, required this.dvrKey, this.lineup, this.dvrs = const []}); } /// Provider for multi-server Plex connections @@ -32,12 +36,22 @@ class MultiServerProvider extends ChangeNotifier { final List _liveTvServers = []; List get liveTvServers => List.unmodifiable(_liveTvServers); + /// Previously-seen set of online server IDs, used to detect new servers + Set _previousOnlineServerIds = {}; + MultiServerProvider(this._serverManager, this._aggregationService) { // Listen to server status changes _statusSubscription = _serverManager.statusStream.listen((_) { + final currentOnline = Set.from(onlineServerIds); + final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id)); + _previousOnlineServerIds = currentOnline; + notifyListeners(); - // Re-check live TV availability when servers come online - checkLiveTvAvailability(); + + // Only re-check live TV when a new server came online + if (hasNewServer) { + checkLiveTvAvailability(); + } }); } @@ -123,7 +137,7 @@ class MultiServerProvider extends ChangeNotifier { try { final dvrs = await client.getDvrs(); for (final dvr in dvrs) { - newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup)); + newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup, dvrs: dvrs)); } } catch (e) { appLogger.d('LiveTV check failed for server $serverId', error: e); diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 7cd255f2..25330081 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -84,6 +84,7 @@ class _DiscoverScreenState extends State final ValueNotifier _indicatorProgress = ValueNotifier(0.0); bool _isAutoScrollPaused = false; HiddenLibrariesProvider? _hiddenLibrariesProvider; + Set _lastSeenHiddenKeys = {}; // WatchStateAware: watch on-deck items and their parent shows/seasons @override @@ -258,6 +259,11 @@ class _DiscoverScreenState extends State } void _onHiddenLibrariesChanged() { + final currentKeys = _hiddenLibrariesProvider?.hiddenLibraryKeys ?? {}; + if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) { + return; // No actual change + } + _lastSeenHiddenKeys = Set.of(currentKeys); _loadContent(); } @@ -451,9 +457,17 @@ class _DiscoverScreenState extends State // Get hidden libraries for filtering final hiddenLibrariesProvider = Provider.of(context, listen: false); + _lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys); // Get settings for hub mode preference (ensure initialized before accessing) final settingsProvider = Provider.of(context, listen: false); + + // Reuse already-loaded libraries to avoid a redundant API call inside hub fetching + final librariesProvider = context.read(); + final librariesByServer = librariesProvider.libraries.isNotEmpty + ? multiServerProvider.aggregationService.groupLibrariesByServer(librariesProvider.libraries) + : null; + await settingsProvider.ensureInitialized(); // Start OnDeck and hubs fetch in parallel @@ -464,6 +478,7 @@ class _DiscoverScreenState extends State final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, useGlobalHubs: settingsProvider.useGlobalHubs, + librariesByServer: librariesByServer, ); // Wait for OnDeck to complete and show it immediately diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index de88cd09..b50ebd71 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -3,7 +3,6 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:dio/dio.dart'; import '../../focus/focus_theme.dart'; import '../../focus/focusable_action_bar.dart'; import '../../focus/focusable_button.dart'; @@ -14,7 +13,6 @@ import '../../mixins/tab_navigation_mixin.dart'; import '../../../services/plex_client.dart'; import '../../models/plex_library.dart'; import '../../models/plex_metadata.dart'; -import '../../models/plex_sort.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; @@ -30,7 +28,6 @@ import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; import '../../mixins/item_updatable.dart'; import '../../i18n/strings.g.dart'; -import '../../utils/error_message_utils.dart'; import 'state_messages.dart'; import 'tabs/library_browse_tab.dart'; import 'tabs/library_recommended_tab.dart'; @@ -96,15 +93,6 @@ class _LibrariesScreenState extends State String? _selectedLibraryGlobalKey; bool _isInitialLoad = true; - Map _selectedFilters = {}; - PlexSort? _selectedSort; - bool _isSortDescending = false; - List _items = []; - int _currentPage = 0; - bool _hasMoreItems = true; - CancelToken? _cancelToken; - int _requestId = 0; - static const int _pageSize = 1000; /// Flag to prevent onTabChanged from focusing when we're programmatically changing tabs bool _isRestoringTab = false; @@ -182,10 +170,6 @@ class _LibrariesScreenState extends State } if (libraryGlobalKeyToLoad != null && mounted) { - final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKeyToLoad); - if (savedFilters.isNotEmpty) { - _selectedFilters = Map.from(savedFilters); - } _loadLibraryContent(libraryGlobalKeyToLoad); } } @@ -337,7 +321,6 @@ class _LibrariesScreenState extends State @override void dispose() { - _cancelToken?.cancel(); _outerScrollController.dispose(); _recommendedTabChipFocusNode.dispose(); _browseTabChipFocusNode.dispose(); @@ -352,15 +335,6 @@ class _LibrariesScreenState extends State setState(fn); } - /// Helper method to get user-friendly error message from exception - String _getErrorMessage(dynamic error, String context) { - if (error is DioException) { - return mapDioErrorToMessage(error, context: context); - } - - return mapUnexpectedErrorToMessage(error, context: context); - } - /// Check if libraries come from multiple servers bool _hasMultipleServers(List libraries) { final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); @@ -392,22 +366,11 @@ class _LibrariesScreenState extends State final libraryIndex = visibleLibraries.indexWhere((lib) => lib.globalKey == libraryGlobalKey); if (libraryIndex == -1) return; // Library not found or hidden - final library = visibleLibraries[libraryIndex]; - - final isChangingLibrary = !_isInitialLoad && _selectedLibraryGlobalKey != libraryGlobalKey; - - // Get the correct client for this library's server - final client = context.getClientForLibrary(library); - _updateState(() { _selectedLibraryGlobalKey = libraryGlobalKey; _errorMessage = null; // Clear loaded tabs tracking for new library _loadedTabs.clear(); - // Only clear filters when explicitly changing library (not on initial load) - if (isChangingLibrary) { - _selectedFilters.clear(); - } }); // Mark that initial load is complete @@ -439,137 +402,11 @@ class _LibrariesScreenState extends State } }); - // Cancel any existing requests - _cancelToken?.cancel(); - _cancelToken = CancelToken(); - final currentRequestId = ++_requestId; - - // Reset pagination state - _updateState(() { - _currentPage = 0; - _hasMoreItems = true; - _items = []; - }); - - try { - // Load sort options for the new library - await _loadSortOptions(library); - - final filtersWithSort = _buildFiltersWithSort(); - - // Load pages sequentially - await _loadAllPagesSequentially(library, filtersWithSort, currentRequestId, client); - } catch (e) { - // Ignore cancellation errors - if (e is DioException && e.type == DioExceptionType.cancel) { - return; - } - - _updateState(() { - _errorMessage = _getErrorMessage(e, 'library content'); - }); - } - } - - /// Load all pages sequentially until all items are fetched - Future _loadAllPagesSequentially( - PlexLibrary library, - Map filtersWithSort, - int requestId, - PlexClient client, - ) async { - while (_hasMoreItems && requestId == _requestId) { - try { - final result = await client.getLibraryContent( - library.key, - start: _currentPage * _pageSize, - size: _pageSize, - filters: filtersWithSort, - cancelToken: _cancelToken, - ); - - // Tag items with server info for multi-server support - final taggedItems = result.items - .map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName)) - .toList(); - - // Check if request is still valid - if (requestId != _requestId) { - return; // Request was superseded - } - - _updateState(() { - _items.addAll(taggedItems); - _currentPage++; - _hasMoreItems = taggedItems.length >= _pageSize; - }); - } catch (e) { - // Check if it's a cancellation - if (e is DioException && e.type == DioExceptionType.cancel) { - return; - } - - // For other errors, update state and rethrow - _updateState(() { - _hasMoreItems = false; - }); - rethrow; - } - } - } - - Future _loadSortOptions(PlexLibrary library) async { - try { - final client = context.getClientForLibrary(library); - - final sortOptions = await client.getLibrarySorts(library.key); - - // Load saved sort preference for this library - final storage = await StorageService.getInstance(); - final savedSortData = storage.getLibrarySort(library.globalKey); - - // Find the saved sort in the options - PlexSort? savedSort; - bool descending = false; - - if (savedSortData != null) { - final sortKey = savedSortData['key'] as String?; - if (sortKey != null) { - savedSort = sortOptions.firstWhere((s) => s.key == sortKey, orElse: () => sortOptions.first); - descending = (savedSortData['descending'] as bool?) ?? false; - } else { - savedSort = sortOptions.first; - } - } else { - savedSort = sortOptions.first; - } - - _updateState(() { - _selectedSort = savedSort; - _isSortDescending = descending; - }); - } catch (e) { - _updateState(() { - _selectedSort = null; - _isSortDescending = false; - }); - } - } - - Map _buildFiltersWithSort() { - final filtersWithSort = Map.from(_selectedFilters); - if (_selectedSort != null) { - filtersWithSort['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending); - } - return filtersWithSort; } @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); - if (index != -1) { - _items[index] = updatedMetadata; - } + // Delegate to the active tab — parent doesn't maintain its own item list } // Public method to refresh content (for normal navigation) @@ -597,8 +434,6 @@ class _LibrariesScreenState extends State appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content'); setState(() { _selectedLibraryGlobalKey = null; - _selectedFilters.clear(); - _items.clear(); _errorMessage = null; }); diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index e6963267..8fa08a60 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -130,21 +130,14 @@ class _LiveTvScreenState extends State 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', ); - // Build a set of enabled channel keys per server from DVR mappings + // Build a set of enabled channel keys per server from cached DVR data final enabledKeysByServer = >{}; - final queriedServers = {}; + final processedServers = {}; for (final serverInfo in liveTvServers) { - if (!queriedServers.add(serverInfo.serverId)) continue; - try { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; - final dvrs = await client.getDvrs(); - final enabledKeys = _extractEnabledChannelKeys(dvrs); - if (enabledKeys != null) { - enabledKeysByServer[serverInfo.serverId] = enabledKeys; - } - } catch (e) { - appLogger.e('Failed to load DVR mappings for server ${serverInfo.serverId}', error: e); + if (!processedServers.add(serverInfo.serverId)) continue; + final enabledKeys = _extractEnabledChannelKeys(serverInfo.dvrs); + if (enabledKeys != null) { + enabledKeysByServer[serverInfo.serverId] = enabledKeys; } } diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 3a38f42b..e3478fd9 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -110,7 +110,7 @@ class MultiServerManager { clientIdentifier: clientIdentifier, ); - final client = PlexClient( + final client = await PlexClient.create( config, serverId: serverId, serverName: server.name, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index a797eb9e..2054a6ca 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -136,6 +136,12 @@ class PlexClient { /// Whether to operate in offline mode (use cache only) bool _offlineMode = false; + /// Libraries parsed from /media/providers (includes individually shared items) + late final List _providerLibraries; + + /// EPG providers parsed from /media/providers + late final List<({String identifier, String gridEndpoint})> _providerEpg; + /// Set offline mode - when true, only cached responses are returned void setOfflineMode(bool offline) { _offlineMode = offline; @@ -157,7 +163,29 @@ class PlexClient { return utf8.decode(responseBytes, allowMalformed: true); } - PlexClient( + /// Create a fully initialized PlexClient. + /// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers. + static Future create( + PlexConfig config, { + required String serverId, + String? serverName, + List? prioritizedEndpoints, + Future Function(String newBaseUrl)? onEndpointChanged, + VoidCallback? onAllEndpointsExhausted, + }) async { + final client = PlexClient._( + config, + serverId: serverId, + serverName: serverName, + prioritizedEndpoints: prioritizedEndpoints, + onEndpointChanged: onEndpointChanged, + onAllEndpointsExhausted: onAllEndpointsExhausted, + ); + await client._initMediaProviders(); + return client; + } + + PlexClient._( this.config, { required this.serverId, this.serverName, @@ -203,6 +231,91 @@ class PlexClient { } } + /// Fetch /media/providers and parse libraries + EPG providers from the response. + /// This discovers individually shared items that don't appear in /library/sections. + Future _initMediaProviders() async { + try { + final response = await _dio.get('/media/providers'); + final container = _getMediaContainer(response); + if (container == null) { + _providerLibraries = []; + _providerEpg = []; + return; + } + + final providers = container['MediaProvider'] as List?; + if (providers == null) { + _providerLibraries = []; + _providerEpg = []; + return; + } + + // Parse libraries from the library provider + final libraries = []; + final epg = <({String identifier, String gridEndpoint})>[]; + + for (final provider in providers) { + if (provider is! Map) continue; + final identifier = provider['identifier'] as String?; + if (identifier == null) continue; + + final features = provider['Feature'] as List?; + if (features == null) continue; + + // Library provider — extract directories as libraries + if (identifier == 'com.plexapp.plugins.library') { + for (final feature in features) { + if (feature is! Map) continue; + if (feature['type'] != 'content') continue; + + final directories = feature['Directory'] as List?; + if (directories == null) continue; + + for (final dir in directories) { + if (dir is! Map) continue; + + // Skip entries without id (Home hub) and playlists + final id = dir['id'] as String?; + if (id == null) continue; + if (dir['type'] == 'playlist') continue; + + // Set key = id so downstream code gets a plain section ID (e.g. "1") + final json = Map.from(dir); + json['key'] = id; + + libraries.add( + PlexLibrary.fromJson(json).copyWith(serverId: serverId, serverName: serverName), + ); + } + } + } + + // EPG provider — extract grid endpoints + final protocols = provider['protocols'] as String?; + if (protocols != null && protocols.contains('livetv')) { + for (final feature in features) { + if (feature is! Map) continue; + if (feature['type'] == 'grid') { + final gridEndpoint = feature['key'] as String?; + if (gridEndpoint != null) { + epg.add((identifier: identifier, gridEndpoint: gridEndpoint)); + appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); + } + } + } + } + } + + _providerLibraries = libraries; + _providerEpg = epg; + appLogger.d('Media providers: ${libraries.length} libraries, ${epg.length} EPG provider(s)'); + } catch (e) { + appLogger.w('Failed to fetch /media/providers, will fall back to /library/sections', error: e); + _providerLibraries = []; + _providerEpg = []; + } + } + /// Update the token used by this client void updateToken(String newToken) { // Update both the Dio headers and the config to ensure consistency @@ -413,8 +526,12 @@ class PlexClient { } /// Get library sections - /// Returns libraries automatically tagged with this client's serverId and serverName + /// Returns libraries automatically tagged with this client's serverId and serverName. + /// Prefers /media/providers data (includes individually shared items), + /// falls back to /library/sections for old servers. Future> getLibraries() async { + if (_providerLibraries.isNotEmpty) return _providerLibraries; + // Fallback for old servers that don't support /media/providers final response = await _dio.get('/library/sections'); return _extractLibraryList(response); } @@ -2112,55 +2229,9 @@ class PlexClient { }, 'Failed to get EPG channels'); } - /// Cached EPG providers (discovered from /media/providers) - List<({String identifier, String gridEndpoint})>? _epgProviders; - - /// Discover all EPG providers from media providers + /// Return EPG providers (already parsed from /media/providers during initialization) Future> _discoverEpgProviders() async { - if (_epgProviders != null) return _epgProviders!; - - try { - final response = await _dio.get('/media/providers'); - final container = _getMediaContainer(response); - if (container == null) return []; - - final providers = container['MediaProvider'] as List?; - if (providers == null) return []; - - final results = <({String identifier, String gridEndpoint})>[]; - - for (final provider in providers) { - if (provider is! Map) continue; - final protocols = provider['protocols'] as String?; - if (protocols == null || !protocols.contains('livetv')) continue; - - final identifier = provider['identifier'] as String?; - if (identifier == null) continue; - - final features = provider['Feature'] as List?; - if (features == null) continue; - for (final feature in features) { - if (feature is! Map) continue; - if (feature['type'] == 'grid') { - final gridEndpoint = feature['key'] as String?; - if (gridEndpoint != null) { - results.add((identifier: identifier, gridEndpoint: gridEndpoint)); - appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); - } - } - } - } - - _epgProviders = results; - appLogger.d('Discovered ${results.length} EPG provider(s)'); - if (results.isEmpty) { - appLogger.w('No EPG providers found'); - } - return results; - } catch (e) { - appLogger.e('Failed to discover EPG providers', error: e); - } - return []; + return _providerEpg; } /// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail.