fix: eliminate duplicate API requests on startup

- 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
This commit is contained in:
edde746
2026-03-15 18:23:32 +01:00
parent a5629c54f6
commit 59b3e6ce81
7 changed files with 167 additions and 235 deletions
+5 -1
View File
@@ -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
+18 -4
View File
@@ -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<LiveTvDvr> 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<LiveTvServerInfo> _liveTvServers = [];
List<LiveTvServerInfo> get liveTvServers => List.unmodifiable(_liveTvServers);
/// Previously-seen set of online server IDs, used to detect new servers
Set<String> _previousOnlineServerIds = {};
MultiServerProvider(this._serverManager, this._aggregationService) {
// Listen to server status changes
_statusSubscription = _serverManager.statusStream.listen((_) {
final currentOnline = Set<String>.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);
+15
View File
@@ -84,6 +84,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
bool _isAutoScrollPaused = false;
HiddenLibrariesProvider? _hiddenLibrariesProvider;
Set<String> _lastSeenHiddenKeys = {};
// WatchStateAware: watch on-deck items and their parent shows/seasons
@override
@@ -258,6 +259,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
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<DiscoverScreen>
// Get hidden libraries for filtering
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
_lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys);
// Get settings for hub mode preference (ensure initialized before accessing)
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
// Reuse already-loaded libraries to avoid a redundant API call inside hub fetching
final librariesProvider = context.read<LibrariesProvider>();
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<DiscoverScreen>
final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers(
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
useGlobalHubs: settingsProvider.useGlobalHubs,
librariesByServer: librariesByServer,
);
// Wait for OnDeck to complete and show it immediately
+1 -166
View File
@@ -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<LibrariesScreen>
String? _selectedLibraryGlobalKey;
bool _isInitialLoad = true;
Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort;
bool _isSortDescending = false;
List<PlexMetadata> _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<LibrariesScreen>
}
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<LibrariesScreen>
@override
void dispose() {
_cancelToken?.cancel();
_outerScrollController.dispose();
_recommendedTabChipFocusNode.dispose();
_browseTabChipFocusNode.dispose();
@@ -352,15 +335,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<PlexLibrary> libraries) {
final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
@@ -392,22 +366,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
}
});
// 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<void> _loadAllPagesSequentially(
PlexLibrary library,
Map<String, String> 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<void> _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<String, String> _buildFiltersWithSort() {
final filtersWithSort = Map<String, String>.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<LibrariesScreen>
appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content');
setState(() {
_selectedLibraryGlobalKey = null;
_selectedFilters.clear();
_items.clear();
_errorMessage = null;
});
+6 -13
View File
@@ -130,21 +130,14 @@ class _LiveTvScreenState extends State<LiveTvScreen>
'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 = <String, Set<String>>{};
final queriedServers = <String>{};
final processedServers = <String>{};
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;
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ class MultiServerManager {
clientIdentifier: clientIdentifier,
);
final client = PlexClient(
final client = await PlexClient.create(
config,
serverId: serverId,
serverName: server.name,
+121 -50
View File
@@ -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<PlexLibrary> _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<PlexClient> create(
PlexConfig config, {
required String serverId,
String? serverName,
List<String>? prioritizedEndpoints,
Future<void> 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<void> _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 = <PlexLibrary>[];
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<String, dynamic>) 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<String, dynamic>.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<List<PlexLibrary>> 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<List<({String identifier, String gridEndpoint})>> _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.