`dart format --set-exit-if-changed` over lib and test rewrites these. The analysis job never reached its formatting step, so the drift went unnoticed. No behaviour changes.
323 lines
13 KiB
Dart
323 lines
13 KiB
Dart
import 'dart:async';
|
|
import '../media/ids.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../media/media_server_client.dart';
|
|
import '../models/livetv_dvr.dart';
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import '../services/plex_client.dart';
|
|
import '../services/data_aggregation_service.dart';
|
|
import '../services/multi_server_manager.dart';
|
|
import '../utils/app_logger.dart';
|
|
|
|
/// Cached info about a DVR-enabled server
|
|
class LiveTvServerInfo {
|
|
final String serverId;
|
|
final String dvrKey;
|
|
final String? 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
|
|
/// Manages multiple PlexClient instances and provides data aggregation
|
|
class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
|
final MultiServerManager _serverManager;
|
|
final DataAggregationService _aggregationService;
|
|
StreamSubscription? _statusSubscription;
|
|
|
|
/// Whether any connected server has Live TV / DVR
|
|
bool _hasLiveTv = false;
|
|
bool get hasLiveTv => _hasLiveTv;
|
|
|
|
/// Info about servers with DVR capability
|
|
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 = {};
|
|
int _liveTvCheckGeneration = 0;
|
|
|
|
/// Invoked with the current visibility-filtered online server ids whenever
|
|
/// the manager's status stream fires (a server connects, reconnects, drops,
|
|
/// or its auth state changes). Lets data providers (`LibrariesProvider`,
|
|
/// `DiscoverProvider`) reload when the online set grows — servers bind in
|
|
/// waves and slow ones reconnect after the initial load — without coupling
|
|
/// the providers by type. Each consumer registers in its constructor and
|
|
/// removes itself in dispose (this provider outlives the profile-scoped
|
|
/// consumers).
|
|
final Set<void Function(Set<String> onlineServerIds)> _onlineServersListeners = {};
|
|
|
|
void addOnlineServersListener(void Function(Set<String> onlineServerIds) listener) {
|
|
_onlineServersListeners.add(listener);
|
|
}
|
|
|
|
void removeOnlineServersListener(void Function(Set<String> onlineServerIds) listener) {
|
|
_onlineServersListeners.remove(listener);
|
|
}
|
|
|
|
@visibleForTesting
|
|
int get onlineServersListenerCount => _onlineServersListeners.length;
|
|
|
|
/// Visibility filter applied by the active app profile. `null` means
|
|
/// "all servers visible" (no profile restriction); otherwise only server
|
|
/// ids in the set surface through [serverIds] / [onlineServerIds].
|
|
/// State lives on [MultiServerManager] so the download client resolver
|
|
/// applies the same filter; this provider owns mutation + notification.
|
|
Set<String>? get _visibleServerIds => _serverManager.visibleServerIds;
|
|
|
|
/// True once the active profile has explicitly resolved visibility. An empty
|
|
/// set is meaningful: the profile has servers, but none are currently visible.
|
|
bool get hasExplicitVisibleServerFilter => _visibleServerIds != null;
|
|
|
|
/// Server ids the active profile is expected to have access to, including
|
|
/// unreachable servers that do not have a live client in [MultiServerManager].
|
|
/// This is intentionally separate from [_visibleServerIds]: visible ids drive
|
|
/// UI/API surfaces, expected ids drive offline/auth decisions.
|
|
Set<String>? _expectedVisibleServerIds;
|
|
|
|
/// Replace the active visibility filter and notify listeners. Pass `null`
|
|
/// to clear the filter (all servers visible). Idempotent — does nothing
|
|
/// when [ids] equals the current filter.
|
|
void setVisibleServerIds(Set<String>? ids) {
|
|
if (setEquals(_visibleServerIds, ids)) return;
|
|
_serverManager.setVisibleServerIds(ids);
|
|
_pruneLiveTvServersForVisibility();
|
|
safeNotifyListeners();
|
|
_refreshLiveTvAvailabilitySoon();
|
|
}
|
|
|
|
/// Replace the expected active-profile server ids. Pass `null` to fall back
|
|
/// to the live visible ids when no profile-scoped expectation is known.
|
|
void setExpectedVisibleServerIds(Set<String>? ids) {
|
|
if (setEquals(_expectedVisibleServerIds, ids)) return;
|
|
// Defensive copy: callers (the binder) keep mutating their set after
|
|
// handing it over, which would silently edit provider state and defeat
|
|
// the idempotence check above.
|
|
_expectedVisibleServerIds = ids == null ? null : Set.of(ids);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Add [serverId] to the active visibility filter. Used after adding a
|
|
/// connection inline (without a profile switch), so the new server
|
|
/// becomes visible without the binder having to re-run. Initializes the
|
|
/// filter to a one-element set when no filter is currently set.
|
|
void addToVisibleServerIds(ServerId serverId) {
|
|
final current = _visibleServerIds;
|
|
if (current != null && current.contains(serverId)) return;
|
|
_serverManager.setVisibleServerIds({...?current, serverId});
|
|
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
|
safeNotifyListeners();
|
|
_refreshLiveTvAvailabilitySoon();
|
|
}
|
|
|
|
/// Keep only ids the manager considers visible under the active filter.
|
|
List<String> _visible(List<String> ids) => ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList();
|
|
|
|
void _pruneLiveTvServersForVisibility() {
|
|
if (_visibleServerIds == null) return;
|
|
_liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId)));
|
|
_hasLiveTv = _liveTvServers.isNotEmpty;
|
|
}
|
|
|
|
void _refreshLiveTvAvailabilitySoon() {
|
|
scheduleMicrotask(() {
|
|
if (!isDisposed) unawaited(checkLiveTvAvailability());
|
|
});
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugSetLiveTvServersForTesting(List<LiveTvServerInfo> servers) {
|
|
_liveTvServers
|
|
..clear()
|
|
..addAll(servers);
|
|
_hasLiveTv = servers.isNotEmpty;
|
|
}
|
|
|
|
MultiServerProvider(this._serverManager, this._aggregationService) {
|
|
// Listen to server status changes
|
|
_statusSubscription = _serverManager.statusStream.listen((_) {
|
|
_promoteOnlineExpectedServers();
|
|
final currentOnline = Set<String>.from(onlineServerIds);
|
|
final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id));
|
|
_previousOnlineServerIds = currentOnline;
|
|
|
|
safeNotifyListeners();
|
|
|
|
// Reload data providers when the online set changes. Each listener owns
|
|
// the "is anything actually new to me?" decision (their loaded sets can
|
|
// differ from _previousOnlineServerIds after a load error or a profile
|
|
// switch that cleared them), so notify unconditionally and let them decide.
|
|
final immutableOnline = Set<String>.unmodifiable(currentOnline);
|
|
for (final listener in List.of(_onlineServersListeners)) {
|
|
listener(immutableOnline);
|
|
}
|
|
|
|
// Only re-check live TV when a new server came online
|
|
if (hasNewServer) {
|
|
checkLiveTvAvailability();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _promoteOnlineExpectedServers() {
|
|
final visible = _visibleServerIds;
|
|
final expected = _expectedVisibleServerIds;
|
|
if (visible == null || expected == null || expected.isEmpty) return;
|
|
|
|
final onlineExpected = _serverManager.onlineServerIds.where(expected.contains).where((id) => !visible.contains(id));
|
|
if (onlineExpected.isEmpty) return;
|
|
|
|
_serverManager.setVisibleServerIds({...visible, ...onlineExpected});
|
|
}
|
|
|
|
/// Get the multi-server manager
|
|
MultiServerManager get serverManager => _serverManager;
|
|
|
|
/// Get the data aggregation service
|
|
DataAggregationService get aggregationService => _aggregationService;
|
|
|
|
/// Get client for specific server.
|
|
MediaServerClient? getClientForServer(ServerId serverId) {
|
|
return _serverManager.getClient(serverId);
|
|
}
|
|
|
|
/// Get the [PlexClient] for a server, or `null` if the server is Jellyfin
|
|
/// (or not registered). Use for Plex-only flows that don't yet have a
|
|
/// backend-neutral equivalent.
|
|
PlexClient? getPlexClientForServer(ServerId serverId) {
|
|
return _serverManager.getPlexClient(serverId);
|
|
}
|
|
|
|
/// Get all online server IDs (visibility-filtered).
|
|
List<String> get onlineServerIds => _visible(_serverManager.onlineServerIds);
|
|
|
|
/// Get all server IDs (visibility-filtered).
|
|
List<String> get serverIds => _visible(_serverManager.serverIds);
|
|
|
|
/// Server ids the active profile is expected to have, including unreachable
|
|
/// Plex servers that have no live client yet.
|
|
List<String> get expectedServerIds {
|
|
final expected = _expectedVisibleServerIds;
|
|
if (expected != null) return expected.toList(growable: false);
|
|
return serverIds;
|
|
}
|
|
|
|
/// Check if a server is online (and visible under the active profile).
|
|
bool isServerOnline(ServerId serverId) =>
|
|
_serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId);
|
|
|
|
/// Get number of online servers
|
|
int get onlineServerCount => onlineServerIds.length;
|
|
|
|
/// Get number of total servers
|
|
int get totalServerCount => serverIds.length;
|
|
|
|
/// Check if any servers are connected
|
|
bool get hasConnectedServers => onlineServerCount > 0;
|
|
|
|
/// Whether at least one online server is a Plex server. Used to gate
|
|
/// Plex-only chrome (server-activities popover, conflict-resolution
|
|
/// helpers) so they don't render against a Jellyfin-only profile.
|
|
bool get hasOnlinePlexServers => onlineServerIds.any((id) => _serverManager.getPlexClient(ServerId(id)) != null);
|
|
|
|
/// Visibility-filtered server ids whose latest health probe was rejected
|
|
/// with HTTP 401/403 (token expired or revoked). UI uses this to show a
|
|
/// "Sign in again" banner distinct from generic "Server offline".
|
|
List<String> get authErrorServerIds {
|
|
final all = _serverManager.authErrorServerIds;
|
|
final filter = _expectedVisibleServerIds ?? _visibleServerIds;
|
|
if (filter == null) return all.toList();
|
|
return all.where(filter.contains).toList();
|
|
}
|
|
|
|
/// Whether any visible server currently has an auth error.
|
|
bool get hasAuthErrorServers => authErrorServerIds.isNotEmpty;
|
|
|
|
/// Display names for the visible auth-errored servers, in stable order.
|
|
/// Falls back to the server id when the client doesn't expose a name.
|
|
List<({ServerId serverId, String displayName})> get authErrorServers {
|
|
return authErrorServerIds
|
|
.map((id) => (serverId: ServerId(id), displayName: _serverManager.serverDisplayName(ServerId(id))))
|
|
.toList();
|
|
}
|
|
|
|
/// Clear all server connections
|
|
void clearAllConnections() {
|
|
_serverManager.disconnectAll();
|
|
_serverManager.setVisibleServerIds(null);
|
|
_expectedVisibleServerIds = null;
|
|
appLogger.d('MultiServerProvider: All connections cleared');
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Check server health for all connected servers
|
|
Future<void> checkServerHealth() async {
|
|
await _serverManager.checkServerHealth();
|
|
// notifyListeners() will be called automatically via status stream
|
|
}
|
|
|
|
/// Check all online servers for DVR/Live TV availability. Plex servers
|
|
/// expose `/livetv/dvrs` (one entry per configured DVR with its own
|
|
/// lineup); Jellyfin servers expose `/LiveTv/Channels` with a single
|
|
/// flat channel list per server (synthesized into one [LiveTvServerInfo]
|
|
/// with `dvrKey: 'jellyfin'` so the rest of the UI's per-DVR loop works
|
|
/// uniformly).
|
|
Future<void> checkLiveTvAvailability() async {
|
|
if (isDisposed) return;
|
|
final generation = ++_liveTvCheckGeneration;
|
|
final newLiveTvServers = <LiveTvServerInfo>[];
|
|
for (final serverId in onlineServerIds) {
|
|
final genericClient = _serverManager.getClient(ServerId(serverId));
|
|
if (genericClient == null) continue;
|
|
|
|
try {
|
|
final liveTv = genericClient.liveTv;
|
|
final dvr = genericClient.liveTvDvr;
|
|
final dvrs = dvr == null ? const <LiveTvDvr>[] : await dvr.fetchDvrs();
|
|
if (dvrs.isNotEmpty) {
|
|
// Plex: one entry per DVR with its own lineup.
|
|
for (final dvr in dvrs) {
|
|
newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup, dvrs: dvrs));
|
|
}
|
|
} else if (await liveTv.isAvailable()) {
|
|
// Jellyfin: no per-DVR partitioning; synthesize a single entry so
|
|
// the rest of the UI's per-DVR loop works uniformly.
|
|
newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: 'jellyfin', lineup: null, dvrs: const []));
|
|
}
|
|
} catch (e) {
|
|
appLogger.d('LiveTV check failed for server $serverId', error: e);
|
|
}
|
|
}
|
|
|
|
final visibleLiveTvServers = newLiveTvServers
|
|
.where((s) => _serverManager.isServerVisible(ServerId(s.serverId)))
|
|
.toList();
|
|
|
|
final hadLiveTv = _hasLiveTv;
|
|
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
|
|
final newServerIds = visibleLiveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
|
|
if (isDisposed || generation != _liveTvCheckGeneration) return;
|
|
_liveTvServers
|
|
..clear()
|
|
..addAll(visibleLiveTvServers);
|
|
_hasLiveTv = visibleLiveTvServers.isNotEmpty;
|
|
|
|
// Notify when availability changes OR when the server set changes
|
|
if (hadLiveTv != _hasLiveTv || !oldServerIds.containsAll(newServerIds) || !newServerIds.containsAll(oldServerIds)) {
|
|
safeNotifyListeners();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
++_liveTvCheckGeneration;
|
|
_statusSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|