diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index 69905633..dabae91f 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -12,6 +12,7 @@ import '../services/settings_service.dart'; import '../services/data_aggregation_service.dart'; import '../services/system_shelf_service.dart'; import '../utils/app_logger.dart'; +import '../utils/deletion_notifier.dart'; import '../utils/global_key_utils.dart'; import '../utils/media_hub_ordering.dart'; import '../utils/watch_state_notifier.dart'; @@ -24,9 +25,11 @@ enum DiscoverLoadState { initial, loading, loaded, error } /// Owns the Discover tab's data: the Continue Watching row and the home hub /// list, including the refresh policy that used to live in the screen — /// watch events refresh only Continue Watching (one on-deck call, zero hub -/// refetches), hidden-library changes trigger a full reload, library-order -/// changes re-sort hubs in place without refetching, and the platform -/// launcher shelf syncs from every on-deck update. +/// refetches), deletions drop the item from every visible list in place and +/// then refresh only Continue Watching, hidden-library changes trigger a +/// full reload, library-order changes re-sort hubs in place without +/// refetching, and the platform launcher shelf syncs from every on-deck +/// update. /// /// Lives inside the profile-keyed provider subtree, so a profile switch /// resets it by construction. The screen is a consumer: it renders this @@ -54,6 +57,14 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin itemIds: () => _watchedIds, onEvent: _onWatchStateChanged, ); + _deletionSubscription = subscribeToHierarchicalEvents( + notifier: DeletionNotifier(), + mounted: () => !isDisposed, + serverId: () => null, + globalKeys: () => _deletionGlobalKeys, + itemIds: () => _deletionIds, + onEvent: _onDeletion, + ); } final MultiServerProvider _multiServer; @@ -68,6 +79,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin final bool Function() isProfileBinding; StreamSubscription? _watchStateSubscription; + StreamSubscription? _deletionSubscription; List _onDeck = []; List _hubs = []; @@ -475,6 +487,73 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin unawaited(refreshContinueWatching()); } + /// Deletions can affect any visible list, so the filter covers on-deck and + /// hub items plus their parents (a deleted season/show takes its visible + /// episodes with it). + Set? get _deletionIds { + final keys = {}; + void addItem(MediaItem item) { + keys.add(item.id); + if (item.parentId != null) keys.add(item.parentId!); + if (item.grandparentId != null) keys.add(item.grandparentId!); + } + + _onDeck.forEach(addItem); + for (final hub in _hubs) { + hub.items.forEach(addItem); + } + return keys; + } + + Set? get _deletionGlobalKeys { + final keys = {}; + bool addItem(MediaItem item) { + final serverId = item.serverId; + if (serverId == null) return false; + + keys.add(buildGlobalKey(ServerId(serverId), item.id)); + if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); + if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); + return true; + } + + for (final item in _onDeck) { + if (!addItem(item)) return null; + } + for (final hub in _hubs) { + for (final item in hub.items) { + if (!addItem(item)) return null; + } + } + return keys; + } + + void _onDeletion(DeletionEvent event) { + // On-deck and hubs are server-backed: a download-only deletion leaves the + // server item in place, so it must not evict anything here. + if (event.isDownloadOnly) return; + + bool affected(MediaItem item) => + item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId; + + var changed = false; + final remainingOnDeck = _onDeck.where((item) => !affected(item)).toList(); + if (remainingOnDeck.length != _onDeck.length) { + _onDeck = remainingOnDeck; + changed = true; + } + for (var i = 0; i < _hubs.length; i++) { + final hub = _hubs[i]; + final newItems = hub.items.where((item) => !affected(item)).toList(); + if (newItems.length != hub.items.length) { + _hubs = List.of(_hubs)..[i] = hub.copyWith(items: newItems); + changed = true; + } + } + if (changed) safeNotifyListeners(); + unawaited(refreshContinueWatching()); + } + void _onHiddenLibrariesChanged() { final currentKeys = _hiddenLibraries.hiddenLibraryKeys; if (currentKeys.length == _lastSeenHiddenKeys.length && currentKeys.containsAll(_lastSeenHiddenKeys)) { @@ -557,6 +636,8 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin _libraries.removeListener(_onLibrariesChanged); _watchStateSubscription?.cancel(); _watchStateSubscription = null; + _deletionSubscription?.cancel(); + _deletionSubscription = null; _pendingSystemShelfItems = null; super.dispose(); } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index ffddd8e7..c473412f 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -8,10 +8,12 @@ import '../../../i18n/strings.g.dart'; import '../../../media/media_hub.dart'; import '../../../media/media_item.dart'; import '../../../media/media_server_client.dart'; +import '../../../mixins/deletion_aware.dart'; import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../services/settings_service.dart'; import '../../../utils/debouncer.dart'; +import '../../../utils/deletion_notifier.dart'; import '../../../utils/global_key_utils.dart'; import '../../../utils/layout_constants.dart'; import '../../../utils/platform_detector.dart'; @@ -44,7 +46,7 @@ class LibraryRecommendedTab extends BaseLibraryTab { } class _LibraryRecommendedTabState extends BaseLibraryTabState - with ItemUpdatable, WatchStateAware { + with ItemUpdatable, WatchStateAware, DeletionAware { /// GlobalKeys for each hub section to enable vertical navigation final List> _hubKeys = []; final _tvBrowseRailKey = GlobalKey(); @@ -94,6 +96,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState widget.library.serverId; + @override + String? get deletionServerId => widget.library.serverId; + + // Deletion filtering needs the same id sets as watch state: each visible + // item plus its parents, so deleting a season/show also matches the + // episodes it contains here. + @override + Set? get deletionIds => watchedIds; + + @override + Set? get deletionGlobalKeys => watchedGlobalKeys; + @override Set? get watchedIds { final keys = {}; @@ -162,11 +176,37 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState item.id == itemId); + } + + @override + void onDeletionEvent(DeletionEvent event) { + // This tab is server-backed: a download-only deletion leaves the server + // item in place, so it must not evict anything here. + if (event.isDownloadOnly) return; + + // Drop the item and any descendants (season/show deletions take their + // episodes with them) from every hub, then resync with the server for + // parent leaf counts and replacement on-deck items — same + // remove-in-place-then-reload shape as the removedFromContinueWatching + // path above. + _removeItemsFromHubs( + hubMatches: (_) => true, + itemMatches: (item) => + item.id == event.itemId || item.parentId == event.itemId || item.grandparentId == event.itemId, + ); + unawaited(loadItems()); + } + + void _removeItemsFromHubs({ + required bool Function(MediaHub) hubMatches, + required bool Function(MediaItem) itemMatches, + }) { setState(() { for (var i = 0; i < items.length; i++) { final hub = items[i]; - if (!_isContinueWatchingHub(hub)) continue; - final newItems = hub.items.where((item) => item.id != itemId).toList(); + if (!hubMatches(hub)) continue; + final newItems = hub.items.where((item) => !itemMatches(item)).toList(); if (newItems.length != hub.items.length) { items[i] = hub.copyWith(items: newItems, size: newItems.length); } diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index c9fe29fc..7876fb82 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -14,18 +14,26 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/prefs.dart'; -MediaItem _item(String id, {String? parentId, String serverId = 'server_1'}) => MediaItem( +MediaItem _item( + String id, { + String? parentId, + String? grandparentId, + MediaKind kind = MediaKind.episode, + String serverId = 'server_1', +}) => MediaItem( id: id, backend: MediaBackend.plex, - kind: MediaKind.episode, + kind: kind, title: id, serverId: serverId, serverName: 'Server', parentId: parentId, + grandparentId: grandparentId, ); MediaHub _hub( @@ -223,6 +231,62 @@ void main() { expect(provider.onDeck.map((i) => i.id), ['ep-2']); }); + test('deletion drops the item from on-deck and hubs, then refreshes continue watching only', () async { + aggregation.onDeckResult = () => [_item('ep-1'), _item('ep-2')]; + aggregation.hubsResult = () => [ + _hub('hub-1', items: [_item('ep-1'), _item('other')]), + ]; + await provider.load(); + final onDeckCallsBefore = aggregation.onDeckCalls; + final hubCallsBefore = aggregation.hubCalls; + + var sawImmediateRemoval = false; + provider.addListener(() { + if (provider.onDeck.length == 1 && provider.onDeck.single.id == 'ep-2') { + sawImmediateRemoval = true; + } + }); + aggregation.onDeckResult = () => [_item('ep-2')]; + + DeletionNotifier().notifyDeletedItem(item: _item('ep-1')); + await pumpEventQueue(); + + expect(sawImmediateRemoval, isTrue); + expect(provider.onDeck.map((i) => i.id), ['ep-2']); + expect(provider.hubs.single.items.map((i) => i.id), ['other']); + expect(aggregation.onDeckCalls, onDeckCallsBefore + 1); + expect(aggregation.hubCalls, hubCallsBefore); + }); + + test('deleting an ancestor removes its episodes from continue watching', () async { + aggregation.onDeckResult = () => [_item('ep-1', grandparentId: 'show-1'), _item('ep-2')]; + await provider.load(); + aggregation.onDeckResult = () => [_item('ep-2')]; + + DeletionNotifier().notifyDeletedItem(item: _item('show-1', kind: MediaKind.show)); + await pumpEventQueue(); + + expect(provider.onDeck.map((i) => i.id), ['ep-2']); + }); + + test('download-only deletion leaves lists untouched and triggers no refetch', () async { + aggregation.onDeckResult = () => [_item('ep-1')]; + aggregation.hubsResult = () => [ + _hub('hub-1', items: [_item('ep-1')]), + ]; + await provider.load(); + final onDeckCallsBefore = aggregation.onDeckCalls; + final hubCallsBefore = aggregation.hubCalls; + + DeletionNotifier().notifyDeletedItem(item: _item('ep-1'), isDownloadOnly: true); + await pumpEventQueue(); + + expect(provider.onDeck.map((i) => i.id), ['ep-1']); + expect(provider.hubs.single.items.map((i) => i.id), ['ep-1']); + expect(aggregation.onDeckCalls, onDeckCallsBefore); + expect(aggregation.hubCalls, hubCallsBefore); + }); + test('library order change re-sorts hubs without any refetch', () async { aggregation.hubsResult = () => [_hub('hub-lib2', libraryId: 'lib-2'), _hub('hub-lib1', libraryId: 'lib-1')]; await provider.load();