import 'dart:async'; import 'package:flutter/foundation.dart'; import '../media/media_item.dart'; import '../media/library_change_event.dart'; import '../media/media_library.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; import '../services/data_aggregation_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import '../utils/library_content_notifier.dart'; import '../utils/coalesced_load_coordinator.dart'; import 'multi_server_provider.dart'; /// Load state for the libraries provider enum LibrariesLoadState { initial, loading, loaded, error } /// Provider that serves as the single source of truth for library data. /// Both SideNavigationRail and LibrariesScreen consume this provider /// instead of independently fetching library data. class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixin { LibrariesProvider({this._storageService, this._multiServer, bool Function()? isProfileBinding}) : _isProfileBinding = isProfileBinding ?? _neverBinding { _loadCoordinator = CoalescedLoadCoordinator(onFull: _loadLibrariesInternal, onDelta: _loadDelta); // Reload libraries when a new server comes online. Servers bind in waves // on sign-in / profile switch and slow ones reconnect after the initial // load; without this they stay missing from the sidebar until a re-switch // or restart. Removed in [dispose] so a profile switch can't leave a // stale listener on the app-global provider. _multiServer?.addOnlineServersListener(syncToOnlineServers); // Server push events mark affected libraries stale; tabs consume the // epoch when they are next shown. Cancelled in [dispose]. _libraryEventSubscription = LibraryContentNotifier().stream.listen(_onLibraryContentChanged); } static bool _neverBinding() => false; final MultiServerProvider? _multiServer; /// Whether the profile binder is still wiring servers — a zero-success /// first load during binding stays in the loading state instead of /// flashing "no libraries" (main_screen primes another load once binding /// settles). final bool Function() _isProfileBinding; StorageService? _storageService; DataAggregationService? _aggregationService; List _libraries = []; LibrariesLoadState _loadState = LibrariesLoadState.initial; String? _errorMessage; late final CoalescedLoadCoordinator _loadCoordinator; /// Server ids whose library fetch *succeeded* in the current [_libraries], as /// reported by [DataAggregationService.getMediaLibrariesFromAllServers]. /// Keyed on fetch success (not on which servers returned libraries) so a /// server that genuinely has zero libraries still counts as loaded, while a /// server whose fetch failed does not — the latter is retried on the next /// status emission instead of being cached as "loaded" forever. Drives /// [syncToOnlineServers]. Set _loadedServerIds = {}; /// Unmodifiable list of all libraries (ordered) List get libraries => List.unmodifiable(_libraries); /// Whether libraries are currently being loaded bool get isLoading => _loadState == LibrariesLoadState.loading; /// Whether libraries have been loaded at least once @visibleForTesting bool get hasLoaded => _loadState == LibrariesLoadState.loaded; /// Current load state @visibleForTesting LibrariesLoadState get loadState => _loadState; /// Error message if loading failed String? get errorMessage => _errorMessage; /// Whether libraries are available bool get hasLibraries => _libraries.isNotEmpty; /// Per-library content epochs, bumped when a server push reports that a /// library's content changed (#1646). A tab records the epoch it loaded /// under and reloads when it is next shown with a newer one — staleness is /// consumed lazily, so a push never live-reloads a grid the user is /// scrolled into. Deliberately does not notify listeners: bumping is /// bookkeeping, not a UI change. final Map _contentEpochByGlobalKey = {}; StreamSubscription? _libraryEventSubscription; /// The current content epoch for the library with [globalKey]; 0 until a /// push event first marks it. int libraryContentEpoch(String globalKey) => _contentEpochByGlobalKey[globalKey] ?? 0; void _onLibraryContentChanged(LibraryChangeEvent event) { if (isDisposed || !event.hasChanges) return; for (final library in _libraries) { if (!eventTargetsLibrary(event, library)) continue; _contentEpochByGlobalKey[library.globalKey] = (_contentEpochByGlobalKey[library.globalKey] ?? 0) + 1; } } /// Single matcher for push events, shared with the visible tab's live pass /// so epoch marking and live refreshes always agree (#1646). An event with /// no library ids targets the whole server; ids that match no loaded /// library on that server (a brand-new library, or a backend id the event /// names differently) fall back to the whole server rather than nothing. bool eventTargetsLibrary(LibraryChangeEvent event, MediaLibrary library) { if (library.serverId == null || library.serverId != event.serverId.value) return false; if (event.libraryIds.isEmpty) return true; if (event.libraryIds.contains(library.id)) return true; return !_serverHasLibraryIn(event.serverId.value, event.libraryIds); } bool _serverHasLibraryIn(String serverId, Set libraryIds) { for (final library in _libraries) { if (library.serverId == serverId && libraryIds.contains(library.id)) return true; } return false; } /// Derived lookups, keyed on the identity of [_libraries]: every mutation /// reassigns the list, so an identical source means the maps are current. List? _lookupSource; Map _byGlobalKey = const {}; Map _libraryCountByServer = const {}; void _ensureLookups() { if (identical(_lookupSource, _libraries)) return; _byGlobalKey = {for (final library in _libraries) library.globalKey: library}; final counts = {}; for (final library in _libraries) { final serverId = library.serverId; if (serverId != null) counts[serverId] = (counts[serverId] ?? 0) + 1; } _libraryCountByServer = counts; _lookupSource = _libraries; } /// The loaded library with [globalKey] (see [MediaLibrary.globalKey]), or /// null while unloaded or for an unknown key. The zero-request resolver for /// items that carry a library id without its title — Plex search rows that /// name their section only by `librarySectionKey` (#1970). MediaLibrary? libraryByGlobalKey(String globalKey) { _ensureLookups(); return _byGlobalKey[globalKey]; } /// Number of loaded libraries on [serverId]; 0 while unloaded. The library /// label on search rows renders only when this is > 1 — attribution on a /// single-library server is noise (#1970). int libraryCountForServer(String serverId) { _ensureLookups(); return _libraryCountByServer[serverId] ?? 0; } /// The library name to attribute [item] with in search results (#1970), or /// null when no label should render: the library is unknown, or the owning /// server has only one. A missing title is resolved against the loaded /// libraries, which covers Plex rows that carry a section id without its /// title. String? libraryLabelFor(MediaItem item) { final serverId = item.serverId; if (serverId == null || libraryCountForServer(serverId) < 2) return null; final title = item.libraryTitle; if (title != null) return title; final globalKey = item.libraryGlobalKey; return globalKey == null ? null : libraryByGlobalKey(globalKey)?.title; } /// Initialize the provider with the aggregation service. /// This should be called after server connection is established. void initialize(DataAggregationService service) { if (isDisposed) return; _aggregationService = service; } /// Reload libraries when the set of online servers has grown since the last /// load. Servers connect in waves — the owner Plex account, then each /// borrowed/shared connection, then Jellyfin, plus slow servers that /// reconnect after timing out — and each wave must surface in the sidebar /// without a profile re-switch or app restart. /// /// No-op when uninitialized, when [onlineServerIds] is empty, or when every /// id is already represented in the current load. That last guard keeps the /// many unrelated reasons the server-status stream fires (visibility churn, /// auth errors, Live TV probes, a server going offline) from causing reload /// storms. /// /// Once a full pass has loaded, only the genuinely new servers are fetched /// and merged in; already-loaded servers are not refetched. Future syncToOnlineServers(Set onlineServerIds) { if (isDisposed || _aggregationService == null || onlineServerIds.isEmpty) return Future.value(); if (_loadState == LibrariesLoadState.loaded && _loadedServerIds.containsAll(onlineServerIds)) { return Future.value(); } // Nothing (or a failed pass) to merge into yet — run the full load. if (_loadState != LibrariesLoadState.loaded) return _load(); return _loadCoordinator.requestDelta(onlineServerIds.difference(_loadedServerIds)); } /// Load libraries from all connected servers, unconditionally. Used by /// pull-to-refresh, inline connection-add, and library reordering. /// Applies saved ordering. Future loadLibraries() => _load(); /// Single entry point for every full (re)load. Each pass fetches whatever is /// online at fetch time, so no caller needs to specify a target. Future _load() { if (isDisposed) return Future.value(); return _loadCoordinator.requestFull(); } /// Fetch libraries from [serverIds] only (servers that came online after /// the last full pass) and merge them into the loaded list. Failures keep /// the current list and leave the ids un-loaded, so the next status /// emission retries them. Future _loadDelta(Set serverIds) async { if (isDisposed) return; // A full pass may have covered these ids while they sat in the queue. final ids = serverIds.difference(_loadedServerIds); if (ids.isEmpty) return; try { final result = await _aggregationService!.getMediaLibrariesFromAllServers(serverIds: ids); if (isDisposed) return; // A pass in which zero servers succeeded is never authoritative: keep // the current list and leave every id un-loaded so the next status // emission retries the whole delta. final succeeded = result.succeededServerIds; if (succeeded.isEmpty) { appLogger.w('LibrariesProvider: delta load failed on all of $ids; keeping previous libraries'); return; } final fresh = result.libraries; // Replace only the entries of servers that actually succeeded. A failed // server keeps its retained libraries and stays out of _loadedServerIds, // so it is refetched instead of wiped. final merged = [ for (final lib in _libraries) if (!succeeded.contains(lib.serverId)) lib, ...fresh, ]; var storage = _storageService; if (storage == null) { storage = await StorageService.getInstance(); if (isDisposed) return; _storageService = storage; } _libraries = _applyLibraryOrder(merged, storage.getLibraryOrder()); // Union *succeeded* ids only, so a server whose fetch failed is retried // on the next status emission instead of being cached as loaded. _loadedServerIds = {..._loadedServerIds, ...succeeded}; appLogger.i('LibrariesProvider: merged ${fresh.length} libraries from $ids'); safeNotifyListeners(); } catch (e, stackTrace) { if (isDisposed) return; appLogger.e('LibrariesProvider: delta load failed for $ids', error: e, stackTrace: stackTrace); } } Future _loadLibrariesInternal() async { if (isDisposed) return; if (_aggregationService == null) { appLogger.w('LibrariesProvider: Cannot load libraries - not initialized'); return; } // Reloading over an already-loaded list (a reactive server-connect sync, an // inline connection add, a reorder) must not flip the UI back to a loading // state: screens such as LibrariesScreen replace their whole body with a // spinner whenever `isLoading` is true. Keep the current list visible and // swap in the fuller one when the fetch completes; only the first load (or // a reload after clear()/error) surfaces the spinner. final reloadInPlace = _loadState == LibrariesLoadState.loaded; if (!reloadInPlace) { _loadState = LibrariesLoadState.loading; _errorMessage = null; safeNotifyListeners(); } try { // Fetch libraries from every connected backend (Plex + Jellyfin). // The aggregation service converts Plex-typed responses to MediaLibrary // internally; Jellyfin clients return MediaLibrary natively. final result = await _aggregationService!.getMediaLibrariesFromAllServers(); if (isDisposed) return; // A pass in which zero servers succeeded is never authoritative — it // must not replace existing data, and it may only commit "loaded, // empty" when the failure is settled (not a client-side abort, not // mid-binding). Recovery is guaranteed: the binding-settle prime and // the next status emission both re-drive a load while state isn't a // fully-covered `loaded`. if (result.succeededServerIds.isEmpty) { if (reloadInPlace) { // A totally-failed silent refresh keeps the last good list instead // of wiping the sidebar. Clear the succeeded set so the next // status emission refetches rather than treating the stale list as // covering those servers. appLogger.w('LibrariesProvider: refresh failed on all servers; keeping previous libraries'); _loadedServerIds = result.succeededServerIds; return; } if (result.cancelledServerIds.isNotEmpty || _isProfileBinding()) { appLogger.d('LibrariesProvider: first load disrupted (zero successful servers); staying in loading state'); return; } } // Apply saved library order var storage = _storageService; if (storage == null) { storage = await StorageService.getInstance(); if (isDisposed) return; _storageService = storage; } final savedOrder = storage.getLibraryOrder(); // Partial-failure rule: a pass is authoritative only for the servers // that actually responded. On an in-place refresh where some servers // failed or were aborted mid-request, mirror _loadDelta's merge — keep // the prior entries of the unreachable servers and replace only the // succeeded servers' entries (including an authoritative empty) — // instead of replacing wholesale, which would blank a server that // merely timed out. Servers absent from the pass entirely (removed // servers) still drop, and _loadedServerIds below excludes the kept // ones so they are refetched rather than cached as loaded. var libraries = result.libraries; if (reloadInPlace) { final unreachable = {...result.failedServerIds, ...result.cancelledServerIds}; if (unreachable.isNotEmpty) { libraries = [ for (final lib in _libraries) if (unreachable.contains(lib.serverId)) lib, ...result.libraries, ]; } } _libraries = _applyLibraryOrder(libraries, savedOrder); // Track which servers actually responded so [syncToOnlineServers] can tell // a genuinely new server from one already covered. Keyed on fetch success // (not on which servers returned libraries) so a zero-library server still // counts as loaded, while a server whose fetch failed is left out and // retried on the next status emission. _loadedServerIds = result.succeededServerIds; _loadState = LibrariesLoadState.loaded; _errorMessage = null; appLogger.i('LibrariesProvider: Loaded ${_libraries.length} libraries'); safeNotifyListeners(); } catch (e, stackTrace) { if (isDisposed) return; appLogger.e('LibrariesProvider: Failed to load libraries', error: e, stackTrace: stackTrace); // A refresh that fails over an existing list keeps the last good data and // `loaded` state instead of blanking to an error screen; the next status // emission re-drives the sync. if (reloadInPlace) return; _loadState = LibrariesLoadState.error; _errorMessage = e.toString(); safeNotifyListeners(); } } /// Refresh libraries by reloading from the connected servers. Future refresh() async { if (isDisposed) return; if (_aggregationService == null) { appLogger.w('LibrariesProvider: Cannot refresh - not initialized'); return; } await loadLibraries(); } /// Update the library order and persist it. Future updateLibraryOrder(List orderedLibraries) async { if (isDisposed) return; _libraries = List.from(orderedLibraries); safeNotifyListeners(); // Save the new order var storage = _storageService; if (storage == null) { storage = await StorageService.getInstance(); if (isDisposed) return; _storageService = storage; } if (isDisposed) return; final libraryKeys = orderedLibraries.map((lib) => lib.globalKey).toList(); await storage.saveLibraryOrder(libraryKeys); if (isDisposed) return; appLogger.d('LibrariesProvider: Updated library order'); } /// Clear all library data (for profile switch or logout). void clear() { if (isDisposed) return; _libraries = []; _loadState = LibrariesLoadState.initial; _errorMessage = null; _loadedServerIds = {}; _loadCoordinator.clearPending(); safeNotifyListeners(); appLogger.d('LibrariesProvider: Cleared library data'); } @override void dispose() { _multiServer?.removeOnlineServersListener(syncToOnlineServers); _libraryEventSubscription?.cancel(); _libraryEventSubscription = null; _loadCoordinator.dispose(); super.dispose(); } /// Apply saved library order to a list of libraries. List _applyLibraryOrder(List libraries, List? savedOrder) { if (savedOrder == null || savedOrder.isEmpty) { return libraries; } final libraryMap = {for (final lib in libraries) lib.globalKey: lib}; final orderedLibraries = []; for (final key in savedOrder) { final lib = libraryMap.remove(key); if (lib != null) { orderedLibraries.add(lib); } } orderedLibraries.addAll(libraryMap.values); return orderedLibraries; } }