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);