Files
plezy/lib/providers/download_metadata_store.dart
edde746 78eedd21d3 style: apply dart format to eight drifted sources
`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.
2026-07-26 23:05:53 +02:00

204 lines
7.7 KiB
Dart

part of 'download_provider.dart';
/// Owns downloaded metadata, artwork references, and profile-scoped watch
/// overlays. [DownloadProvider] remains responsible for queue and ownership
/// orchestration; this store keeps cache hydration and watch synchronization in
/// one lifecycle-bound component.
class _DownloadMetadataStore extends ChangeNotifier {
_DownloadMetadataStore(this._downloadManager, this._database, {String? activeProfileId})
: _activeProfileId = activeProfileId {
_watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged);
_watchStateStore.addListener(notifyListeners);
_watchStateStore.setActiveProfileId(activeProfileId);
}
final DownloadManagerService _downloadManager;
final AppDatabase _database;
final WatchStateStore _watchStateStore = WatchStateStore();
late final StreamSubscription<WatchStateEvent> _watchStateSubscription;
Future<void> _watchStateWriteTail = Future<void>.value();
final Map<String, MediaItem> items = {};
final Map<String, DownloadedArtwork> artworkPaths = {};
final Map<String, String?> _watchScopesByServer = {};
String? _activeProfileId;
void setActiveProfileId(String? profileId) {
if (_activeProfileId == profileId) return;
_activeProfileId = profileId;
_watchScopesByServer.clear();
_watchStateStore
..setActiveProfileId(profileId)
..setActiveClientScopesByServer(const {});
}
MediaItem applyWatchState(MediaItem item) => _watchStateStore.apply(item);
MediaItem? resolved(String globalKey) {
final item = items[globalKey];
return item == null ? null : applyWatchState(item);
}
Map<String, MediaItem> get resolvedItems =>
Map.unmodifiable({for (final entry in items.entries) entry.key: applyWatchState(entry.value)});
/// Loads show/season or artist/album metadata from an already-fetched cache
/// snapshot without issuing per-parent database queries.
void loadParentMetadataFromMap(MediaItem leaf, Map<String, MediaItem> allMetadata, {String? clientScopeId}) {
final serverId = leaf.serverId;
if (serverId == null) return;
MediaItem? lookupParent(String ratingKey) {
if (clientScopeId != null && clientScopeId.isNotEmpty) {
final scoped = allMetadata[buildGlobalKey(ServerId(clientScopeId), ratingKey)];
if (scoped != null) return scoped;
}
return allMetadata[buildGlobalKey(ServerId(serverId), ratingKey)];
}
void loadParent(String? ratingKey) {
if (ratingKey == null) return;
final parentGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey);
if (items.containsKey(parentGlobalKey)) return;
final parentMetadata = lookupParent(ratingKey);
if (parentMetadata == null) return;
items[parentGlobalKey] = parentMetadata;
if (parentMetadata.thumbPath != null) {
artworkPaths[parentGlobalKey] = DownloadedArtwork(thumbPath: parentMetadata.thumbPath);
}
}
loadParent(leaf.grandparentId);
loadParent(leaf.parentId);
}
/// Rehydrates queued offline watch actions into the canonical hierarchy-aware
/// watch-state layer for the active profile.
Future<void> hydrateOfflineWatchOverlay({bool Function()? isStale}) async {
bool stale() => isStale?.call() ?? false;
try {
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) {
_watchStateStore.setHydratedPatches(const []);
return;
}
final keys = <String>{};
for (final item in items.values) {
keys.add(item.globalKey);
final serverId = serverIdOrNull(item.serverId);
if (serverId == null) continue;
for (final parentId in item.parentChain) {
keys.add(buildGlobalKey(serverId, parentId));
}
}
if (keys.isEmpty) {
_watchStateStore.setHydratedPatches(const []);
return;
}
final scopes = <String, String?>{};
final scopesByServer = <String, String?>{};
for (final key in keys) {
final parsed = parseGlobalKey(key);
if (parsed == null) continue;
var scope = scopesByServer[parsed.serverId];
if (!scopesByServer.containsKey(parsed.serverId)) {
scope = await _offlineWatchScopeForServer(parsed.serverId);
scopesByServer[parsed.serverId] = scope;
}
scopes[key] = scope;
if (stale()) return;
}
_watchScopesByServer
..clear()
..addAll(scopesByServer);
_watchStateStore.setActiveClientScopesByServer(_watchScopesByServer);
final actions = await _database.getWatchActionsForKeys(
keys,
profileId: profileId,
filterProfile: true,
clientScopeIdsByGlobalKey: scopes,
);
if (stale()) return;
final hydrated = <HydratedWatchStatePatch>[];
for (final entry in actions.entries) {
final snapshot = WatchStateResolver.fromActions(entry.value);
if (snapshot.isEmpty) continue;
final latest = entry.value.firstWhere(
(action) =>
action.actionType == 'watched' || action.actionType == 'unwatched' || action.actionType == 'progress',
);
final scopedKey = latest.clientScopeId != null && latest.clientScopeId!.isNotEmpty
? buildGlobalKey(ServerId(latest.clientScopeId!), latest.ratingKey)
: latest.globalKey;
hydrated.add(
HydratedWatchStatePatch(globalKey: scopedKey, patch: snapshot, updatedAt: latest.updatedAt, order: latest.id),
);
}
_watchStateStore.setHydratedPatches(hydrated);
} catch (error) {
appLogger.w('Failed to apply offline watch overlay', error: error);
}
}
Future<String?> _offlineWatchScopeForServer(String serverId) async {
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return null;
return _downloadManager.profileClientScopeIdForServer(ServerId(serverId), profileId);
}
Future<void> waitForWatchStateWrites() async {
// The notifier's broadcast stream delivers asynchronously. Yield once so
// all events queued by the caller are represented in the write tail.
await Future<void>.delayed(Duration.zero);
await _watchStateWriteTail;
}
void _onWatchStateChanged(WatchStateEvent event) {
final snapshot = WatchStateResolver.fromEvent(event);
if (snapshot.isEmpty) return;
final globalKey = buildGlobalKey(ServerId(event.serverId), event.itemId);
final base = items[globalKey];
final eventScope = event.cacheServerId;
final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(event.serverId));
if (activeScope != null && activeScope.isNotEmpty) {
_watchScopesByServer[event.serverId] = activeScope;
_watchStateStore.setActiveClientScopesByServer(_watchScopesByServer);
}
if (base == null || activeScope == null || activeScope.isEmpty) return;
if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) {
return;
}
final isWatched = snapshot.isWatched;
final shouldPersistToCache =
isWatched != null && (event.changeType != WatchStateChangeType.progressUpdate || event.isNowWatched == true);
if (!shouldPersistToCache) return;
_watchStateWriteTail = _watchStateWriteTail
.then(
(_) => ApiCache.forBackend(
base.backend,
).applyWatchState(serverId: ServerId(activeScope), itemId: event.itemId, isWatched: isWatched),
)
.catchError((Object error, StackTrace stackTrace) {
appLogger.w('Failed to apply watch state to cache for $globalKey', error: error, stackTrace: stackTrace);
});
}
@override
void dispose() {
_watchStateSubscription.cancel();
_watchStateStore
..removeListener(notifyListeners)
..dispose();
super.dispose();
}
}