Files
plezy/lib/providers/libraries_provider.dart
edde746 5a666fd10f feat(discover): refresh home and library content from server library push events
New media added server-side was invisible while the app was open - the
client had no channel for library-change notifications, so the home
screen and library tabs sat stale until an app restart.

Each online server now runs a reconnecting websocket push channel
(Plex `/:/websockets/notifications`; Jellyfin/Emby `/socket` with the
dialect deltas, including Emby's capabilities registration), owned per
server by a LibraryEventService supervisor that follows server
online/offline/replaced-client transitions and app lifecycle. Channels
emit one coalesced LibraryChangeEvent per burst on a leading-edge
throttle modeled on Plex Web's repopulate pacing: the first settled
change surfaces immediately and a library scan's flood merges behind
it. Connection failure degrades silently to the stale-refresh paths -
bounded reconnect backoff, re-armed on every status sync.

Consumers pace their reaction through one shared RefreshPacer: burst
debounce, blocked retry while video playback or an active scroll owns
the surface, a cooldown bounding pass frequency during bulk imports,
and credit for committed pull passes so a push landing right after a
fresh load defers to the cooldown's trailing edge.

- Discover runs a debounced full pass and swaps the result in place;
  the hero carousel resets only when a pass lands first content, so a
  push never yanks a screen the user is reading. Pushed removals drop
  from every visible list immediately via the deletion bus, scoped to
  the emitting server.
- The visible library tab swaps its data in place: transaction tabs
  reload without clearing, and the browse and paginated card grids
  refetch their loaded span (Plex Web's repopulateRange) with the
  scroll offset anchored on the first visible item and the span
  clamped after alpha jumps. Hidden tabs - and tabs behind another
  main tab - mark per-library staleness epochs instead and reload when
  next shown; epochs are snapshotted at load start so a push racing an
  in-flight fetch stays stale.
- The stale-resume and tab-shown paths refetch home hubs (previously
  Continue Watching only), covering setups where the socket cannot
  connect.

close #1646
2026-08-31 18:52:03 +02:00

443 lines
19 KiB
Dart

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<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);
// 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<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;
/// 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<String, int> _contentEpochByGlobalKey = {};
StreamSubscription<LibraryChangeEvent>? _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<String> 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<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);
_libraryEventSubscription?.cancel();
_libraryEventSubscription = null;
_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;
}
}