Files
plezy/lib/providers/libraries_provider.dart
T
edde746 d7422c81cb fix(providers): keep reconnects, retained libraries, and Discord presence alive after failures
Companion-remote reconnects derived their intent from a session status the peer overwrites mid-join, so a failed reconnect attempt often never rescheduled; the attempt now carries its own intent flag.

Library loads dropped retained state on failure. A delta load that failed for every requested server removed their libraries while adding nothing, and an in-place refresh where one server succeeded and another timed out replaced the whole list with only the successful response, wiping the failed server's sidebar entries even though it stayed out of the loaded set. Both paths now key retention to the servers that actually responded: unreachable servers keep their entries and are refetched on the next status emission, removed servers still drop.

Discord RPC kept the dead client after a disconnect, making every 30s reconnect a no-op for the rest of the session; disconnect now tears the client down so the timer builds a fresh one. Rapid disable/enable while a connect was still initializing also let the old client's failure dispose the fresh one, so each attempt binds its listeners and cleanup to its own client instance and stands down when it is no longer current.
2026-08-21 19:23:40 +02:00

394 lines
16 KiB
Dart

import 'package:flutter/foundation.dart';
import '../media/media_item.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/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<String>(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);
}
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<MediaLibrary> _libraries = [];
LibrariesLoadState _loadState = LibrariesLoadState.initial;
String? _errorMessage;
late final CoalescedLoadCoordinator<String> _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<String> _loadedServerIds = {};
/// Unmodifiable list of all libraries (ordered)
List<MediaLibrary> 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;
/// Derived lookups, keyed on the identity of [_libraries]: every mutation
/// reassigns the list, so an identical source means the maps are current.
List<MediaLibrary>? _lookupSource;
Map<String, MediaLibrary> _byGlobalKey = const {};
Map<String, int> _libraryCountByServer = const {};
void _ensureLookups() {
if (identical(_lookupSource, _libraries)) return;
_byGlobalKey = {for (final library in _libraries) library.globalKey: library};
final counts = <String, int>{};
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<void> syncToOnlineServers(Set<String> onlineServerIds) {
if (isDisposed || _aggregationService == null || onlineServerIds.isEmpty) return Future<void>.value();
if (_loadState == LibrariesLoadState.loaded && _loadedServerIds.containsAll(onlineServerIds)) {
return Future<void>.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<void> 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<void> _load() {
if (isDisposed) return Future<void>.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<void> _loadDelta(Set<String> 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<void> _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<void> 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<void> updateLibraryOrder(List<MediaLibrary> 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);
_loadCoordinator.dispose();
super.dispose();
}
/// Apply saved library order to a list of libraries.
List<MediaLibrary> _applyLibraryOrder(List<MediaLibrary> libraries, List<String>? savedOrder) {
if (savedOrder == null || savedOrder.isEmpty) {
return libraries;
}
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
final orderedLibraries = <MediaLibrary>[];
for (final key in savedOrder) {
final lib = libraryMap.remove(key);
if (lib != null) {
orderedLibraries.add(lib);
}
}
orderedLibraries.addAll(libraryMap.values);
return orderedLibraries;
}
}