fix(hubs): drop deleted items from recommended and home continue watching

The library Recommended tab never subscribed to deletion events, so
"Delete from server" left the episode sitting in Continue Watching
until a full reload. Make the tab DeletionAware (remove in place across
all hubs, then resync) and give DiscoverProvider the same subscription
so the home row and hubs drop deleted items too.

close #1486
This commit is contained in:
edde746
2026-07-05 05:58:52 +02:00
parent 7b1c150e18
commit 6e83aeda88
3 changed files with 193 additions and 8 deletions
+84 -3
View File
@@ -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<DeletionEvent>(
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<WatchStateEvent>? _watchStateSubscription;
StreamSubscription<DeletionEvent>? _deletionSubscription;
List<MediaItem> _onDeck = [];
List<MediaHub> _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<String>? get _deletionIds {
final keys = <String>{};
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<String>? get _deletionGlobalKeys {
final keys = <String>{};
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();
}
@@ -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<MediaHub> {
}
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
with ItemUpdatable, WatchStateAware {
with ItemUpdatable, WatchStateAware, DeletionAware {
/// GlobalKeys for each hub section to enable vertical navigation
final List<GlobalKey<HubSectionState>> _hubKeys = [];
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
@@ -94,6 +96,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
@override
String? get watchStateServerId => 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<String>? get deletionIds => watchedIds;
@override
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
@override
Set<String>? get watchedIds {
final keys = <String>{};
@@ -162,11 +176,37 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
}
void _removeContinueWatchingItem(String itemId) {
_removeItemsFromHubs(hubMatches: _isContinueWatchingHub, itemMatches: (item) => 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);
}
+66 -2
View File
@@ -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();