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 _watchStateSubscription; Future _watchStateWriteTail = Future.value(); final Map items = {}; final Map artworkPaths = {}; final Map _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 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 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 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 = {}; 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 = {}; final scopesByServer = {}; 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 = []; 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 _offlineWatchScopeForServer(String serverId) async { final profileId = _activeProfileId; if (profileId == null || profileId.isEmpty) return null; return _downloadManager.profileClientScopeIdForServer(ServerId(serverId), profileId); } Future 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.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(); } }