From 841d336cb8bf7f94194c8828c4cc658f56dd44e7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:44:52 +0200 Subject: [PATCH] fix(downloads): rederive show rule download links before cleanup Return initialized show and season rules from the backfill query so their coverage is recomputed from download ancestry. Rule execution links only the unwatched episodes it inspected, so the cached flag let a sibling list cleanup delete episodes the show rule covers. --- lib/database/app_database.dart | 9 ++++- lib/database/app_database.g.dart | 6 +-- lib/database/tables.dart | 6 +-- test/providers/download_provider_test.dart | 47 ++++++++++++++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 19827976..fbff4e13 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -18,6 +18,7 @@ import '../services/credential_vault.dart'; import '../utils/app_logger.dart'; import '../utils/serial_future_queue.dart'; import '../utils/global_key_utils.dart'; +import '../utils/content_utils.dart'; part 'app_database.g.dart'; @@ -1057,12 +1058,18 @@ class AppDatabase extends _$AppDatabase { ); } + /// Returns uninitialized collection/playlist rules and every show/season + /// rule whose local ancestry-derived links must be refreshed before cleanup. Future> getUninitializedSyncRulesForServer({ required String profileId, required ServerId serverId, }) { return (select(syncRules)..where( - (t) => t.profileId.equals(profileId) & t.serverId.equals(serverId) & t.downloadLinksInitialized.equals(false), + (t) => + t.profileId.equals(profileId) & + t.serverId.equals(serverId) & + (t.downloadLinksInitialized.equals(false) | + t.targetType.isIn(const [ContentTypes.show, ContentTypes.season])), )) .get(); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 7934ca45..19bc7399 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -3568,9 +3568,9 @@ class SyncRuleItem extends DataClass implements Insertable { final String downloadFilter; final bool includeSpecials; - /// Whether every currently-owned candidate has been associated in - /// [SyncRuleDownloads]. Existing rules start false and are backfilled before - /// destructive cleanup. + /// Gates collection/playlist backfill into [SyncRuleDownloads] before + /// destructive cleanup. Show/season coverage is re-derived from + /// [DownloadedMedia] ancestry at cleanup time regardless of this value. final bool downloadLinksInitialized; const SyncRuleItem({ required this.id, diff --git a/lib/database/tables.dart b/lib/database/tables.dart index e8571f27..6e17d9f6 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -106,9 +106,9 @@ class SyncRules extends Table { TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))(); BoolColumn get includeSpecials => boolean().withDefault(const Constant(true))(); - /// Whether every currently-owned candidate has been associated in - /// [SyncRuleDownloads]. Existing rules start false and are backfilled before - /// destructive cleanup. + /// Gates collection/playlist backfill into [SyncRuleDownloads] before + /// destructive cleanup. Show/season coverage is re-derived from + /// [DownloadedMedia] ancestry at cleanup time regardless of this value. BoolColumn get downloadLinksInitialized => boolean().withDefault(const Constant(false))(); } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 20ba3bc6..5528d649 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -755,6 +755,53 @@ void main() { expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared'), isNotNull); }); + test('deleteSyncRuleAndDownloads keeps episodes an initialized show rule covers', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + addTearDown(p.dispose); + await p.ensureInitialized(); + final serverManager = MultiServerManager(); + addTearDown(serverManager.dispose); + + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'playlist', targetType: 'playlist', episodeCount: 0); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'show-1', targetType: 'show', episodeCount: 1); + final targetKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist'); + final showKey = p.syncRuleKeyFor(ServerId('srv'), 'show-1'); + final targetRule = (await db.getSyncRule(targetKey))!; + final showRule = (await db.getSyncRule(showKey))!; + await db.markSyncRuleDownloadLinksInitialized(targetKey); + await db.markSyncRuleDownloadLinksInitialized(showKey); + + const downloadKey = 'srv:ep-watched'; + final episode = testMediaItem( + id: 'ep-watched', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Watched episode', + serverId: ServerId('srv'), + ); + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'ep-watched', + globalKey: downloadKey, + type: 'episode', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: downloadKey); + await db.associateSyncRuleDownload(targetRule, downloadKey); + + p.debugSeedState( + downloads: {downloadKey: const DownloadProgress(globalKey: downloadKey, status: DownloadStatus.completed)}, + metadata: {downloadKey: episode}, + ownedDownloadKeys: {downloadKey}, + ); + + await p.deleteSyncRuleAndDownloads(targetKey, serverManager); + + expect(await db.getDownloadedMedia(downloadKey), isNotNull); + expect(await db.getSyncRuleDownloadLinks(showRule.id), hasLength(1)); + }); + test('deleteSyncRuleAndDownloads keeps an unresolvable legacy list rule intact', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); addTearDown(p.dispose);