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:
@@ -31,7 +31,11 @@ class HiddenLibrariesProvider extends ChangeNotifier {
|
|||||||
_storageService = await StorageService.getInstance();
|
_storageService = await StorageService.getInstance();
|
||||||
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
|
_hiddenLibraryKeys = _storageService.getHiddenLibraries();
|
||||||
_isInitialized = true;
|
_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
|
/// Hide a library by its key
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/livetv_dvr.dart';
|
||||||
import '../services/plex_client.dart';
|
import '../services/plex_client.dart';
|
||||||
import '../services/data_aggregation_service.dart';
|
import '../services/data_aggregation_service.dart';
|
||||||
import '../services/multi_server_manager.dart';
|
import '../services/multi_server_manager.dart';
|
||||||
@@ -14,7 +15,10 @@ class LiveTvServerInfo {
|
|||||||
final String dvrKey;
|
final String dvrKey;
|
||||||
final String? lineup;
|
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
|
/// Provider for multi-server Plex connections
|
||||||
@@ -32,12 +36,22 @@ class MultiServerProvider extends ChangeNotifier {
|
|||||||
final List<LiveTvServerInfo> _liveTvServers = [];
|
final List<LiveTvServerInfo> _liveTvServers = [];
|
||||||
List<LiveTvServerInfo> get liveTvServers => List.unmodifiable(_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) {
|
MultiServerProvider(this._serverManager, this._aggregationService) {
|
||||||
// Listen to server status changes
|
// Listen to server status changes
|
||||||
_statusSubscription = _serverManager.statusStream.listen((_) {
|
_statusSubscription = _serverManager.statusStream.listen((_) {
|
||||||
|
final currentOnline = Set<String>.from(onlineServerIds);
|
||||||
|
final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id));
|
||||||
|
_previousOnlineServerIds = currentOnline;
|
||||||
|
|
||||||
notifyListeners();
|
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 {
|
try {
|
||||||
final dvrs = await client.getDvrs();
|
final dvrs = await client.getDvrs();
|
||||||
for (final dvr in dvrs) {
|
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) {
|
} catch (e) {
|
||||||
appLogger.d('LiveTV check failed for server $serverId', error: e);
|
appLogger.d('LiveTV check failed for server $serverId', error: e);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
||||||
bool _isAutoScrollPaused = false;
|
bool _isAutoScrollPaused = false;
|
||||||
HiddenLibrariesProvider? _hiddenLibrariesProvider;
|
HiddenLibrariesProvider? _hiddenLibrariesProvider;
|
||||||
|
Set<String> _lastSeenHiddenKeys = {};
|
||||||
|
|
||||||
// WatchStateAware: watch on-deck items and their parent shows/seasons
|
// WatchStateAware: watch on-deck items and their parent shows/seasons
|
||||||
@override
|
@override
|
||||||
@@ -258,6 +259,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onHiddenLibrariesChanged() {
|
void _onHiddenLibrariesChanged() {
|
||||||
|
final currentKeys = _hiddenLibrariesProvider?.hiddenLibraryKeys ?? {};
|
||||||
|
if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) {
|
||||||
|
return; // No actual change
|
||||||
|
}
|
||||||
|
_lastSeenHiddenKeys = Set.of(currentKeys);
|
||||||
_loadContent();
|
_loadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,9 +457,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
|
|
||||||
// Get hidden libraries for filtering
|
// Get hidden libraries for filtering
|
||||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||||
|
_lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys);
|
||||||
|
|
||||||
// Get settings for hub mode preference (ensure initialized before accessing)
|
// Get settings for hub mode preference (ensure initialized before accessing)
|
||||||
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
|
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();
|
await settingsProvider.ensureInitialized();
|
||||||
|
|
||||||
// Start OnDeck and hubs fetch in parallel
|
// Start OnDeck and hubs fetch in parallel
|
||||||
@@ -464,6 +478,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers(
|
final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers(
|
||||||
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
|
||||||
useGlobalHubs: settingsProvider.useGlobalHubs,
|
useGlobalHubs: settingsProvider.useGlobalHubs,
|
||||||
|
librariesByServer: librariesByServer,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for OnDeck to complete and show it immediately
|
// Wait for OnDeck to complete and show it immediately
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:plezy/widgets/app_icon.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import '../../focus/focus_theme.dart';
|
import '../../focus/focus_theme.dart';
|
||||||
import '../../focus/focusable_action_bar.dart';
|
import '../../focus/focusable_action_bar.dart';
|
||||||
import '../../focus/focusable_button.dart';
|
import '../../focus/focusable_button.dart';
|
||||||
@@ -14,7 +13,6 @@ import '../../mixins/tab_navigation_mixin.dart';
|
|||||||
import '../../../services/plex_client.dart';
|
import '../../../services/plex_client.dart';
|
||||||
import '../../models/plex_library.dart';
|
import '../../models/plex_library.dart';
|
||||||
import '../../models/plex_metadata.dart';
|
import '../../models/plex_metadata.dart';
|
||||||
import '../../models/plex_sort.dart';
|
|
||||||
import '../../providers/hidden_libraries_provider.dart';
|
import '../../providers/hidden_libraries_provider.dart';
|
||||||
import '../../providers/libraries_provider.dart';
|
import '../../providers/libraries_provider.dart';
|
||||||
import '../../providers/multi_server_provider.dart';
|
import '../../providers/multi_server_provider.dart';
|
||||||
@@ -30,7 +28,6 @@ import '../../services/storage_service.dart';
|
|||||||
import '../../mixins/refreshable.dart';
|
import '../../mixins/refreshable.dart';
|
||||||
import '../../mixins/item_updatable.dart';
|
import '../../mixins/item_updatable.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../utils/error_message_utils.dart';
|
|
||||||
import 'state_messages.dart';
|
import 'state_messages.dart';
|
||||||
import 'tabs/library_browse_tab.dart';
|
import 'tabs/library_browse_tab.dart';
|
||||||
import 'tabs/library_recommended_tab.dart';
|
import 'tabs/library_recommended_tab.dart';
|
||||||
@@ -96,15 +93,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
String? _selectedLibraryGlobalKey;
|
String? _selectedLibraryGlobalKey;
|
||||||
bool _isInitialLoad = true;
|
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
|
/// Flag to prevent onTabChanged from focusing when we're programmatically changing tabs
|
||||||
bool _isRestoringTab = false;
|
bool _isRestoringTab = false;
|
||||||
@@ -182,10 +170,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (libraryGlobalKeyToLoad != null && mounted) {
|
if (libraryGlobalKeyToLoad != null && mounted) {
|
||||||
final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKeyToLoad);
|
|
||||||
if (savedFilters.isNotEmpty) {
|
|
||||||
_selectedFilters = Map.from(savedFilters);
|
|
||||||
}
|
|
||||||
_loadLibraryContent(libraryGlobalKeyToLoad);
|
_loadLibraryContent(libraryGlobalKeyToLoad);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +321,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cancelToken?.cancel();
|
|
||||||
_outerScrollController.dispose();
|
_outerScrollController.dispose();
|
||||||
_recommendedTabChipFocusNode.dispose();
|
_recommendedTabChipFocusNode.dispose();
|
||||||
_browseTabChipFocusNode.dispose();
|
_browseTabChipFocusNode.dispose();
|
||||||
@@ -352,15 +335,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
setState(fn);
|
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
|
/// Check if libraries come from multiple servers
|
||||||
bool _hasMultipleServers(List<PlexLibrary> libraries) {
|
bool _hasMultipleServers(List<PlexLibrary> libraries) {
|
||||||
final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
|
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);
|
final libraryIndex = visibleLibraries.indexWhere((lib) => lib.globalKey == libraryGlobalKey);
|
||||||
if (libraryIndex == -1) return; // Library not found or hidden
|
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(() {
|
_updateState(() {
|
||||||
_selectedLibraryGlobalKey = libraryGlobalKey;
|
_selectedLibraryGlobalKey = libraryGlobalKey;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
// Clear loaded tabs tracking for new library
|
// Clear loaded tabs tracking for new library
|
||||||
_loadedTabs.clear();
|
_loadedTabs.clear();
|
||||||
// Only clear filters when explicitly changing library (not on initial load)
|
|
||||||
if (isChangingLibrary) {
|
|
||||||
_selectedFilters.clear();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark that initial load is complete
|
// 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
|
@override
|
||||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
// Delegate to the active tab — parent doesn't maintain its own item list
|
||||||
if (index != -1) {
|
|
||||||
_items[index] = updatedMetadata;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public method to refresh content (for normal navigation)
|
// 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');
|
appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content');
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedLibraryGlobalKey = null;
|
_selectedLibraryGlobalKey = null;
|
||||||
_selectedFilters.clear();
|
|
||||||
_items.clear();
|
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,21 +130,14 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}',
|
'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 enabledKeysByServer = <String, Set<String>>{};
|
||||||
final queriedServers = <String>{};
|
final processedServers = <String>{};
|
||||||
for (final serverInfo in liveTvServers) {
|
for (final serverInfo in liveTvServers) {
|
||||||
if (!queriedServers.add(serverInfo.serverId)) continue;
|
if (!processedServers.add(serverInfo.serverId)) continue;
|
||||||
try {
|
final enabledKeys = _extractEnabledChannelKeys(serverInfo.dvrs);
|
||||||
final client = multiServer.getClientForServer(serverInfo.serverId);
|
if (enabledKeys != null) {
|
||||||
if (client == null) continue;
|
enabledKeysByServer[serverInfo.serverId] = enabledKeys;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class MultiServerManager {
|
|||||||
clientIdentifier: clientIdentifier,
|
clientIdentifier: clientIdentifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
final client = PlexClient(
|
final client = await PlexClient.create(
|
||||||
config,
|
config,
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
serverName: server.name,
|
serverName: server.name,
|
||||||
|
|||||||
+121
-50
@@ -136,6 +136,12 @@ class PlexClient {
|
|||||||
/// Whether to operate in offline mode (use cache only)
|
/// Whether to operate in offline mode (use cache only)
|
||||||
bool _offlineMode = false;
|
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
|
/// Set offline mode - when true, only cached responses are returned
|
||||||
void setOfflineMode(bool offline) {
|
void setOfflineMode(bool offline) {
|
||||||
_offlineMode = offline;
|
_offlineMode = offline;
|
||||||
@@ -157,7 +163,29 @@ class PlexClient {
|
|||||||
return utf8.decode(responseBytes, allowMalformed: true);
|
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, {
|
this.config, {
|
||||||
required this.serverId,
|
required this.serverId,
|
||||||
this.serverName,
|
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
|
/// Update the token used by this client
|
||||||
void updateToken(String newToken) {
|
void updateToken(String newToken) {
|
||||||
// Update both the Dio headers and the config to ensure consistency
|
// Update both the Dio headers and the config to ensure consistency
|
||||||
@@ -413,8 +526,12 @@ class PlexClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get library sections
|
/// 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 {
|
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');
|
final response = await _dio.get('/library/sections');
|
||||||
return _extractLibraryList(response);
|
return _extractLibraryList(response);
|
||||||
}
|
}
|
||||||
@@ -2112,55 +2229,9 @@ class PlexClient {
|
|||||||
}, 'Failed to get EPG channels');
|
}, 'Failed to get EPG channels');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached EPG providers (discovered from /media/providers)
|
/// Return EPG providers (already parsed from /media/providers during initialization)
|
||||||
List<({String identifier, String gridEndpoint})>? _epgProviders;
|
|
||||||
|
|
||||||
/// Discover all EPG providers from media providers
|
|
||||||
Future<List<({String identifier, String gridEndpoint})>> _discoverEpgProviders() async {
|
Future<List<({String identifier, String gridEndpoint})>> _discoverEpgProviders() async {
|
||||||
if (_epgProviders != null) return _epgProviders!;
|
return _providerEpg;
|
||||||
|
|
||||||
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 [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail.
|
/// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail.
|
||||||
|
|||||||
Reference in New Issue
Block a user