From 699e73bcd551923ce834a098f32563b91eaa66ca Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:34:42 +0200 Subject: [PATCH] fix(downloads): propagate hierarchical watch state --- lib/providers/download_provider.dart | 133 ++++++++++---- lib/providers/watch_state_store.dart | 95 +++++++++- test/providers/download_provider_test.dart | 203 +++++++++++++++++++++ test/providers/watch_state_store_test.dart | 47 +++++ 4 files changed, 435 insertions(+), 43 deletions(-) diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 28c50d34..e7d414ef 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -20,6 +20,7 @@ import '../services/download_storage_service.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; import '../services/watch_state_resolver.dart'; +import 'watch_state_store.dart'; import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; @@ -62,6 +63,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin StreamSubscription? _progressSubscription; StreamSubscription? _deletionProgressSubscription; StreamSubscription? _watchStateSubscription; + final WatchStateStore _watchStateStore = WatchStateStore(); late final Future _initFuture; // Track download progress by public globalKey (serverId:ratingKey). @@ -74,6 +76,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Store Plex thumb paths for offline display (actual file path computed from hash) final Map _artworkPaths = {}; + final Map _watchScopesByServer = {}; // Track items currently being queued (building download queue) final Set _queueing = {}; @@ -106,6 +109,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Keep cached metadata fresh when items get marked watched/unwatched anywhere // in the app, so re-entering a screen reflects the latest state. _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); + _watchStateStore.addListener(_onWatchStateOverlayChanged); // Load persisted downloads from database _initFuture = _loadPersistedDownloads(); @@ -125,6 +129,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); + _watchStateStore.addListener(_onWatchStateOverlayChanged); + _watchStateStore.setActiveProfileId(_activeProfileId); _initFuture = _loadProfileScopedState(); } @@ -144,6 +150,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; _activeProfileId = profileId; + _watchStateStore.setActiveProfileId(profileId); + _watchScopesByServer.clear(); + _watchStateStore.setActiveClientScopesByServer(const {}); _profileGeneration++; final reload = _reloadProfileScopedStateForActiveProfile(); _profileScopedReloadFuture = reload; @@ -152,10 +161,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future _reloadProfileScopedStateForActiveProfile() async { final targetProfileId = _activeProfileId; + final targetGeneration = _profileGeneration; await _initFuture; - if (_activeProfileId != targetProfileId) return; + if (_activeProfileId != targetProfileId || _profileGeneration != targetGeneration) return; await _loadProfileScopedState(); - if (_activeProfileId == targetProfileId) { + await _applyOfflineWatchOverlay(expectedProfileGeneration: targetGeneration); + if (_activeProfileId == targetProfileId && _profileGeneration == targetGeneration) { safeNotifyListeners(); } } @@ -365,48 +376,91 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - /// Patch `_metadata` viewCount/viewOffsetMs from queued OfflineWatchProgress - /// actions. Idempotent and cheap (one batched DB read). + /// Hydrate queued OfflineWatchProgress actions into the canonical + /// hierarchy-aware watch-state layer. Future _applyOfflineWatchOverlay({int? expectedProfileGeneration}) async { - if (_metadata.isEmpty) return; bool isStale() => expectedProfileGeneration != null && (isDisposed || expectedProfileGeneration != _profileGeneration); try { - final keys = _metadata.keys.toSet(); + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) { + _watchStateStore.setHydratedPatches(const []); + return; + } + final keys = {}; + for (final item in _metadata.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) { - scopes[key] = await _offlineWatchScopeForGlobalKey(key); + 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 (isStale()) return; } - final profileId = _activeProfileId; + _watchScopesByServer + ..clear() + ..addAll(scopesByServer); + _watchStateStore.setActiveClientScopesByServer(_watchScopesByServer); final actions = await _database.getWatchActionsForKeys( keys, profileId: profileId, - filterProfile: profileId != null, + filterProfile: true, clientScopeIdsByGlobalKey: scopes, ); if (isStale()) return; - if (actions.isEmpty) return; + final hydrated = []; for (final entry in actions.entries) { - final base = _metadata[entry.key]; - if (base == null) continue; final snapshot = WatchStateResolver.fromActions(entry.value); if (snapshot.isEmpty) continue; - _metadata[entry.key] = snapshot.apply(base); + 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: WatchStatePatch.fromSnapshot(snapshot), + updatedAt: latest.updatedAt, + order: latest.id, + ), + ); } + _watchStateStore.setHydratedPatches(hydrated); } catch (e) { appLogger.w('Failed to apply offline watch overlay', error: e); } } - Future _offlineWatchScopeForGlobalKey(String globalKey) async { - final parsed = parseGlobalKey(globalKey); - if (parsed == null) return null; - final activeScope = _downloadManager.activeClientScopeIdForServer(parsed.serverId); + Future _offlineWatchScopeForServer(String serverId) async { + final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(serverId)); if (activeScope != null && activeScope.isNotEmpty) return activeScope; - final downloaded = await _database.getDownloadedMedia(globalKey); - final downloadedScope = downloaded?.clientScopeId; - return downloadedScope == null || downloadedScope.isEmpty ? null : downloadedScope; + for (final globalKey in _downloads.keys) { + if (!_ownsDownloadKey(globalKey)) continue; + final parsed = parseGlobalKey(globalKey); + if (parsed?.serverId != serverId) continue; + final downloadedScope = (await _database.getDownloadedMedia(globalKey))?.clientScopeId; + if (downloadedScope != null && downloadedScope.isNotEmpty) return downloadedScope; + } + return null; } /// Load parent metadata (show + season for episodes, artist + album for @@ -459,24 +513,30 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _progressSubscription?.cancel(); _deletionProgressSubscription?.cancel(); _watchStateSubscription?.cancel(); + _watchStateStore.removeListener(_onWatchStateOverlayChanged); + _watchStateStore.dispose(); super.dispose(); } + void _onWatchStateOverlayChanged() => safeNotifyListeners(); + void _onWatchStateChanged(WatchStateEvent event) { final snapshot = WatchStateResolver.fromEvent(event); if (snapshot.isEmpty) return; final globalKey = buildGlobalKey(ServerId(event.serverId), event.itemId); final base = _metadata[globalKey]; - if (base == null) return; 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) return; if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) { return; } - _metadata[globalKey] = snapshot.apply(base); - final isWatched = snapshot.isWatched; // Sub-threshold progress ticks are frequent; offline reloads re-apply them // from queued watch actions, so only durable watch flips hit the cache here. @@ -507,7 +567,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin }), ); } - safeNotifyListeners(); } /// Ensure metadata has a serverId, falling back to a parent's serverId. @@ -519,7 +578,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Map.unmodifiable(Map.fromEntries(_downloads.entries.where(_ownsProgressEntry))); /// All metadata for downloads - Map get metadata => Map.unmodifiable(_metadata); + Map get metadata => + Map.unmodifiable({for (final entry in _metadata.entries) entry.key: _watchStateStore.apply(entry.value)}); /// Get unique TV shows that have downloaded episodes /// Returns stored show metadata, or synthesizes from episode metadata as fallback @@ -529,7 +589,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final entry in _metadata.entries) { final globalKey = entry.key; if (!_ownsDownloadKey(globalKey)) continue; - final meta = entry.value; + final meta = _watchStateStore.apply(entry.value); final progress = _downloads[globalKey]; if (progress?.status == DownloadStatus.completed && meta.isEpisode) { @@ -537,7 +597,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (showRatingKey != null && !shows.containsKey(showRatingKey)) { // Try to get stored show metadata first final showGlobalKey = buildGlobalKey(ServerId(meta.serverId!), showRatingKey); - final storedShow = _metadata[showGlobalKey]; + final storedShow = _resolvedMetadata(showGlobalKey); if (storedShow != null && storedShow.isShow) { // Use stored show metadata (has year, summary, clearLogo) @@ -577,7 +637,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final progress = _downloads[entry.key]; return progress?.status == DownloadStatus.completed && entry.value.isMovie; }) - .map((entry) => entry.value) + .map((entry) => _watchStateStore.apply(entry.value)) .toList(); } @@ -590,7 +650,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin for (final entry in _metadata.entries) { final globalKey = entry.key; if (!_ownsDownloadKey(globalKey)) continue; - final meta = entry.value; + final meta = _watchStateStore.apply(entry.value); if (meta.kind != MediaKind.track) continue; if (_downloads[globalKey]?.status != DownloadStatus.completed) continue; @@ -598,7 +658,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (albumRatingKey == null || albums.containsKey(albumRatingKey)) continue; final albumGlobalKey = buildGlobalKey(ServerId(meta.serverId!), albumRatingKey); - final storedAlbum = _metadata[albumGlobalKey]; + final storedAlbum = _resolvedMetadata(albumGlobalKey); if (storedAlbum != null && storedAlbum.kind == MediaKind.album) { albums[albumRatingKey] = storedAlbum; } else { @@ -635,7 +695,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin meta.parentId == albumRatingKey && _downloads[entry.key]?.status == DownloadStatus.completed; }) - .map((entry) => entry.value) + .map((entry) => _watchStateStore.apply(entry.value)) .toList(); tracks.sort((a, b) { final byDisc = (a.discNumber ?? 1).compareTo(b.discNumber ?? 1); @@ -646,7 +706,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Get metadata for a specific download - MediaItem? getMetadata(String globalKey) => _metadata[globalKey]; + MediaItem? _resolvedMetadata(String globalKey) { + final item = _metadata[globalKey]; + return item == null ? null : _watchStateStore.apply(item); + } + + MediaItem? getMetadata(String globalKey) => _resolvedMetadata(globalKey); /// Get artwork paths for a specific download (for offline display) DownloadedArtwork? getArtworkPaths(String globalKey) => _artworkPaths[globalKey]; @@ -667,7 +732,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final meta = entry.value; return progress?.status == DownloadStatus.completed && meta.isEpisode && meta.grandparentId == showRatingKey; }) - .map((entry) => entry.value) + .map((entry) => _watchStateStore.apply(entry.value)) .toList(); } @@ -1656,7 +1721,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin .toList(); for (final globalKey in completedKeys) { - final meta = _metadata[globalKey]; + final meta = _resolvedMetadata(globalKey); if (meta == null) continue; if (!meta.isEpisode && !meta.isMovie) continue; if (!meta.isWatched) continue; diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index d3a15069..bf98c0c0 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -37,11 +37,50 @@ class WatchStatePatch { int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); } +@immutable +class HydratedWatchStatePatch { + final String globalKey; + final WatchStatePatch patch; + final int updatedAt; + final int order; + + const HydratedWatchStatePatch({ + required this.globalKey, + required this.patch, + required this.updatedAt, + required this.order, + }); +} + class _WatchStatePatchEntry { final WatchStatePatch patch; + final int updatedAt; final int sequence; + final bool isSessionEvent; - const _WatchStatePatchEntry(this.patch, this.sequence); + const _WatchStatePatchEntry( + this.patch, { + required this.updatedAt, + required this.sequence, + required this.isSessionEvent, + }); + + bool isNewerThan(_WatchStatePatchEntry other) { + if (updatedAt != other.updatedAt) return updatedAt > other.updatedAt; + if (isSessionEvent != other.isSessionEvent) return isSessionEvent; + return sequence > other.sequence; + } + + @override + bool operator ==(Object other) => + other is _WatchStatePatchEntry && + other.patch == patch && + other.updatedAt == updatedAt && + other.sequence == sequence && + other.isSessionEvent == isSessionEvent; + + @override + int get hashCode => Object.hash(patch, updatedAt, sequence, isSessionEvent); } /// The single session-local layer for watch-state freshness. @@ -60,23 +99,32 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin StreamSubscription? _subscription; final Map _patches = {}; + final Map _hydratedPatches = {}; String? _activeProfileId; Map _activeClientScopesByServer = const {}; int _sequence = 0; + _WatchStatePatchEntry? _exactEntryFor(String globalKey) { + final session = _patches[globalKey]; + final hydrated = _hydratedPatches[globalKey]; + if (session == null) return hydrated; + if (hydrated == null) return session; + return session.isNewerThan(hydrated) ? session : hydrated; + } + _WatchStatePatchEntry? _entryFor(String globalKey) { _WatchStatePatchEntry? scopedEntry; final parsed = parseGlobalKey(globalKey); if (parsed != null) { final scoped = _activeClientScopesByServer[parsed.serverId]; if (scoped != null && scoped.isNotEmpty) { - scopedEntry = _patches[buildGlobalKey(ServerId(scoped), parsed.ratingKey)]; + scopedEntry = _exactEntryFor(buildGlobalKey(ServerId(scoped), parsed.ratingKey)); } } - final unscopedEntry = _patches[globalKey]; + final unscopedEntry = _exactEntryFor(globalKey); if (scopedEntry == null) return unscopedEntry; if (unscopedEntry == null) return scopedEntry; - return scopedEntry.sequence >= unscopedEntry.sequence ? scopedEntry : unscopedEntry; + return scopedEntry.isNewerThan(unscopedEntry) ? scopedEntry : unscopedEntry; } WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; @@ -88,7 +136,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin for (final parentId in item.parentChain) { // Mirror MediaItem.globalKey's bare-id fallback when serverId is missing. final entry = _entryFor(serverId != null ? buildGlobalKey(serverId, parentId) : parentId); - if (entry != null && (best == null || entry.sequence > best.sequence)) best = entry; + if (entry != null && (best == null || entry.isNewerThan(best))) best = entry; } } return best?.patch; @@ -99,7 +147,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin } List applyAll(List items) { - if (_patches.isEmpty) return items; + if (_patches.isEmpty && _hydratedPatches.isEmpty) return items; return [for (final item in items) apply(item)]; } @@ -115,8 +163,9 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; _activeProfileId = profileId; - if (_patches.isEmpty) return; + if (_patches.isEmpty && _hydratedPatches.isEmpty) return; _patches.clear(); + _hydratedPatches.clear(); safeNotifyListeners(); } @@ -127,7 +176,30 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin }; if (mapEquals(_activeClientScopesByServer, normalized)) return; _activeClientScopesByServer = Map.unmodifiable(normalized); - if (_patches.isNotEmpty) safeNotifyListeners(); + if (_patches.isNotEmpty || _hydratedPatches.isNotEmpty) safeNotifyListeners(); + } + + /// Replace the persisted local-action layer without disturbing newer + /// session events. Timestamps preserve freshness across item/ancestor keys. + void setHydratedPatches(Iterable patches) { + final next = {}; + for (final hydrated in patches) { + final candidate = _WatchStatePatchEntry( + hydrated.patch, + updatedAt: hydrated.updatedAt, + sequence: hydrated.order, + isSessionEvent: false, + ); + final existing = next[hydrated.globalKey]; + if (existing == null || candidate.isNewerThan(existing)) { + next[hydrated.globalKey] = candidate; + } + } + if (mapEquals(_hydratedPatches, next)) return; + _hydratedPatches + ..clear() + ..addAll(next); + safeNotifyListeners(); } void _onWatchStateEvent(WatchStateEvent event) { @@ -139,7 +211,12 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId ? buildGlobalKey(ServerId(cacheServerId), event.itemId) : event.globalKey; - _patches[key] = _WatchStatePatchEntry(patch, ++_sequence); + _patches[key] = _WatchStatePatchEntry( + patch, + updatedAt: DateTime.now().millisecondsSinceEpoch, + sequence: ++_sequence, + isSessionEvent: true, + ); safeNotifyListeners(); } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 41d13fa7..2c32c420 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -1114,6 +1114,209 @@ void main() { p.dispose(); }); + + test('show, season, and episode events resolve by hierarchical freshness', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + final show = MediaItem( + id: 'show-1', + backend: MediaBackend.plex, + kind: MediaKind.show, + serverId: ServerId('srv'), + leafCount: 3, + viewedLeafCount: 0, + ); + final season1 = MediaItem( + id: 'season-1', + backend: MediaBackend.plex, + kind: MediaKind.season, + parentId: 'show-1', + serverId: ServerId('srv'), + leafCount: 2, + viewedLeafCount: 0, + ); + final episode1 = MediaItem( + id: 'episode-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + parentId: 'season-1', + grandparentId: 'show-1', + serverId: ServerId('srv'), + viewCount: 0, + viewOffsetMs: 40000, + ); + final episode2 = episode1.copyWith(id: 'episode-2', viewOffsetMs: 50000); + final episode3 = episode1.copyWith(id: 'episode-3', parentId: 'season-2', viewOffsetMs: 60000); + p.debugSeedState( + metadata: { + show.globalKey: show, + season1.globalKey: season1, + episode1.globalKey: episode1, + episode2.globalKey: episode2, + episode3.globalKey: episode3, + }, + ); + + WatchStateNotifier().notifyWatched(item: show); + await Future.delayed(Duration.zero); + expect(p.getMetadata(episode1.globalKey)?.isWatched, isTrue); + expect(p.getMetadata(episode2.globalKey)?.viewOffsetMs, 0); + expect(p.getMetadata(episode3.globalKey)?.isWatched, isTrue); + + WatchStateNotifier().notifyWatched(item: season1, isNowWatched: false); + await Future.delayed(Duration.zero); + expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode2.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode3.globalKey)?.isWatched, isTrue); + + WatchStateNotifier().notifyWatched(item: episode1); + await Future.delayed(Duration.zero); + expect(p.getMetadata(episode1.globalKey)?.isWatched, isTrue); + expect(p.getMetadata(episode2.globalKey)?.isWatched, isFalse); + + WatchStateNotifier().notifyWatched(item: show, isNowWatched: false); + await Future.delayed(Duration.zero); + expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode2.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode3.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode3.globalKey)?.viewOffsetMs, 0); + + p.dispose(); + }); + + test('queued parent and episode overrides survive provider reload', () async { + await db.insertWatchAction( + profileId: 'profile-a', + serverId: ServerId('srv'), + ratingKey: 'show-1', + actionType: 'watched', + ); + await db.insertWatchAction( + profileId: 'profile-a', + serverId: ServerId('srv'), + ratingKey: 'episode-1', + actionType: 'unwatched', + ); + + final episode1 = MediaItem( + id: 'episode-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + parentId: 'season-1', + grandparentId: 'show-1', + serverId: ServerId('srv'), + viewCount: 0, + viewOffsetMs: 45000, + ); + final episode2 = episode1.copyWith(id: 'episode-2', viewOffsetMs: 55000); + + Future hydrate() async { + final provider = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + await provider.ensureInitialized(); + provider.debugSeedState(metadata: {episode1.globalKey: episode1, episode2.globalKey: episode2}); + await provider.refreshMetadataFromCache(); + return provider; + } + + var p = await hydrate(); + expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode1.globalKey)?.viewOffsetMs, 0); + expect(p.getMetadata(episode2.globalKey)?.isWatched, isTrue); + expect(p.getMetadata(episode2.globalKey)?.viewOffsetMs, 0); + p.dispose(); + + p = await hydrate(); + expect(p.getMetadata(episode1.globalKey)?.isWatched, isFalse); + expect(p.getMetadata(episode2.globalKey)?.isWatched, isTrue); + p.dispose(); + }); + + test('queued overlays are isolated to the active profile', () async { + await db.insertWatchAction( + profileId: 'profile-a', + serverId: ServerId('srv'), + ratingKey: 'show-1', + actionType: 'watched', + ); + await db.insertWatchAction( + profileId: 'profile-b', + serverId: ServerId('srv'), + ratingKey: 'show-1', + actionType: 'unwatched', + ); + final episode = MediaItem( + id: 'episode-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + parentId: 'season-1', + grandparentId: 'show-1', + serverId: ServerId('srv'), + viewCount: 0, + ); + final p = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + await p.ensureInitialized(); + p.debugSeedState(metadata: {episode.globalKey: episode}); + + await p.refreshMetadataFromCache(); + expect(p.getMetadata(episode.globalKey)?.isWatched, isTrue); + + p.setActiveProfileId('profile-b'); + await p.refreshMetadataFromCache(); + expect(p.getMetadata(episode.globalKey)?.isWatched, isFalse); + + p.dispose(); + }); + + test('queued overlays are isolated to the active Jellyfin client scope', () async { + await db.insertWatchAction( + profileId: 'test-profile', + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-a', + ratingKey: 'show-1', + actionType: 'watched', + ); + await db.insertWatchAction( + profileId: 'test-profile', + serverId: ServerId('jf-machine'), + clientScopeId: 'jf-machine/user-b', + ratingKey: 'show-1', + actionType: 'unwatched', + ); + final episode = MediaItem( + id: 'episode-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + parentId: 'season-1', + grandparentId: 'show-1', + serverId: ServerId('jf-machine'), + viewCount: 0, + ); + var activeScope = 'jf-machine/user-a'; + testClientResolver = (serverId, {clientScopeId}) => serverId == 'jf-machine' + ? _ScopedTestClient(serverId: ServerId('jf-machine'), scopedServerId: activeScope) + : null; + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState(metadata: {episode.globalKey: episode}); + + await p.refreshMetadataFromCache(); + expect(p.getMetadata(episode.globalKey)?.isWatched, isTrue); + + activeScope = 'jf-machine/user-b'; + await p.refreshMetadataFromCache(); + expect(p.getMetadata(episode.globalKey)?.isWatched, isFalse); + + p.dispose(); + }); }); group('DownloadProvider — progress stream', () { diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart index 7ecf6144..3bde8d69 100644 --- a/test/providers/watch_state_store_test.dart +++ b/test/providers/watch_state_store_test.dart @@ -157,4 +157,51 @@ void main() { expect(resolved.viewedLeafCount, 10); expect(resolved.isWatched, isTrue); }); + + test('hydrated parent and item patches retain persisted freshness', () { + final store = WatchStateStore(); + addTearDown(store.dispose); + store.setHydratedPatches(const [ + HydratedWatchStatePatch( + globalKey: 'jf-machine:show-1', + patch: WatchStatePatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), + updatedAt: 100, + order: 1, + ), + HydratedWatchStatePatch( + globalKey: 'jf-machine:episode-1', + patch: WatchStatePatch(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), + updatedAt: 200, + order: 2, + ), + ]); + + final resolved = store.apply(_episode.copyWith(viewOffsetMs: 30000)); + expect(resolved.isWatched, isFalse); + expect(resolved.viewOffsetMs, 0); + }); + + test('hydrated patches are isolated to the active client scope', () { + final store = WatchStateStore(); + addTearDown(store.dispose); + store.setHydratedPatches(const [ + HydratedWatchStatePatch( + globalKey: 'jf-machine/user-a:show-1', + patch: WatchStatePatch(isWatched: true), + updatedAt: 100, + order: 1, + ), + HydratedWatchStatePatch( + globalKey: 'jf-machine/user-b:show-1', + patch: WatchStatePatch(isWatched: false), + updatedAt: 100, + order: 2, + ), + ]); + + store.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-a'}); + expect(store.apply(_episode).isWatched, isTrue); + store.setActiveClientScopesByServer({'jf-machine': 'jf-machine/user-b'}); + expect(store.apply(_episode).isWatched, isFalse); + }); }