diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 01f4f65a..8d9398cc 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -62,6 +62,7 @@ final class AppDatabaseBootstrap { ApiCache, OfflineWatchProgress, SyncRules, + SyncRuleDownloads, Connections, Profiles, ProfileConnections, @@ -495,7 +496,7 @@ class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 19; + int get schemaVersion => 20; @override MigrationStrategy get migration { @@ -911,6 +912,18 @@ class AppDatabase extends _$AppDatabase { WHERE backend IS NULL '''); } + if (from < 20) { + appLogger.i('Adding sync rule download associations (v20 migration)'); + await _ignoreAlreadyExists( + 'SyncRules.downloadLinksInitialized column', + () => m.addColumn(syncRules, syncRules.downloadLinksInitialized), + ); + await _ignoreAlreadyExists('SyncRuleDownloads table', () => m.createTable(syncRuleDownloads)); + await _ignoreAlreadyExists( + 'Index idx_sync_rule_downloads_profile_key', + () => m.create(idxSyncRuleDownloadsProfileKey), + ); + } }, ); } @@ -1230,6 +1243,81 @@ class AppDatabase extends _$AppDatabase { return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull(); } + Future associateSyncRuleDownload(SyncRuleItem rule, String downloadGlobalKey) { + return into(syncRuleDownloads).insertOnConflictUpdate( + SyncRuleDownloadsCompanion.insert( + syncRuleId: rule.id, + profileId: rule.profileId, + downloadGlobalKey: downloadGlobalKey, + ), + ); + } + + Future markSyncRuleDownloadLinksInitialized(String globalKey) { + return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( + const SyncRulesCompanion(downloadLinksInitialized: Value(true)), + ); + } + + 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), + )) + .get(); + } + + Future> getSyncRuleDownloadLinks(int syncRuleId) { + return (select(syncRuleDownloads)..where((t) => t.syncRuleId.equals(syncRuleId))).get(); + } + + Future> getOwnedDownloadKeysForAncestorRule({ + required String profileId, + required ServerId serverId, + required String ratingKey, + required bool matchGrandparent, + }) async { + final query = select( + downloadedMedia, + ).join([innerJoin(downloadOwners, downloadOwners.globalKey.equalsExp(downloadedMedia.globalKey))]); + final ancestorMatches = matchGrandparent + ? downloadedMedia.grandparentRatingKey.equals(ratingKey) | downloadedMedia.parentRatingKey.equals(ratingKey) + : downloadedMedia.parentRatingKey.equals(ratingKey); + query.where( + downloadOwners.profileId.equals(profileId) & + downloadedMedia.serverId.equals(serverId) & + downloadedMedia.status.isIn([ + DownloadStatus.queued.index, + DownloadStatus.downloading.index, + DownloadStatus.completed.index, + DownloadStatus.paused.index, + ]) & + ancestorMatches, + ); + final rows = await query.get(); + return rows.map((row) => row.readTable(downloadedMedia).globalKey).toList(growable: false); + } + + Future> getExclusiveSyncRuleDownloadKeys(SyncRuleItem rule) async { + final links = await getSyncRuleDownloadLinks(rule.id); + if (links.isEmpty) return const []; + + final keys = links.map((link) => link.downloadGlobalKey).toSet(); + final allLinks = await (select( + syncRuleDownloads, + )..where((t) => t.profileId.equals(rule.profileId) & t.downloadGlobalKey.isIn(keys))).get(); + final linkedRuleCounts = {}; + for (final link in allLinks) { + linkedRuleCounts.update(link.downloadGlobalKey, (count) => count + 1, ifAbsent: () => 1); + } + return [ + for (final key in keys) + if (linkedRuleCounts[key] == 1) key, + ]; + } + Future insertSyncRule({ String profileId = '', required ServerId serverId, @@ -1290,6 +1378,9 @@ class AppDatabase extends _$AppDatabase { await (update(syncRules)..where((t) => t.id.equals(rule.id))).write( SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)), ); + await (update( + syncRuleDownloads, + )..where((t) => t.syncRuleId.equals(rule.id))).write(SyncRuleDownloadsCompanion(profileId: Value(profileId))); } } @@ -1317,6 +1408,15 @@ class AppDatabase extends _$AppDatabase { ); } + Future completeSyncRuleExecution(String globalKey) { + return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( + SyncRulesCompanion( + lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch), + downloadLinksInitialized: const Value(true), + ), + ); + } + Future deleteSyncRule(String globalKey) async { await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go(); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 3e48f63d..7934ca45 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -3327,6 +3327,21 @@ class $SyncRulesTable extends SyncRules ), defaultValue: const Constant(true), ); + static const VerificationMeta _downloadLinksInitializedMeta = + const VerificationMeta('downloadLinksInitialized'); + @override + late final GeneratedColumn downloadLinksInitialized = + GeneratedColumn( + 'download_links_initialized', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("download_links_initialized" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -3342,6 +3357,7 @@ class $SyncRulesTable extends SyncRules mediaIndex, downloadFilter, includeSpecials, + downloadLinksInitialized, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -3454,6 +3470,15 @@ class $SyncRulesTable extends SyncRules ), ); } + if (data.containsKey('download_links_initialized')) { + context.handle( + _downloadLinksInitializedMeta, + downloadLinksInitialized.isAcceptableOrUnknown( + data['download_links_initialized']!, + _downloadLinksInitializedMeta, + ), + ); + } return context; } @@ -3515,6 +3540,10 @@ class $SyncRulesTable extends SyncRules DriftSqlType.bool, data['${effectivePrefix}include_specials'], )!, + downloadLinksInitialized: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}download_links_initialized'], + )!, ); } @@ -3538,6 +3567,11 @@ class SyncRuleItem extends DataClass implements Insertable { final int mediaIndex; 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. + final bool downloadLinksInitialized; const SyncRuleItem({ required this.id, required this.profileId, @@ -3552,6 +3586,7 @@ class SyncRuleItem extends DataClass implements Insertable { required this.mediaIndex, required this.downloadFilter, required this.includeSpecials, + required this.downloadLinksInitialized, }); @override Map toColumns(bool nullToAbsent) { @@ -3571,6 +3606,9 @@ class SyncRuleItem extends DataClass implements Insertable { map['media_index'] = Variable(mediaIndex); map['download_filter'] = Variable(downloadFilter); map['include_specials'] = Variable(includeSpecials); + map['download_links_initialized'] = Variable( + downloadLinksInitialized, + ); return map; } @@ -3591,6 +3629,7 @@ class SyncRuleItem extends DataClass implements Insertable { mediaIndex: Value(mediaIndex), downloadFilter: Value(downloadFilter), includeSpecials: Value(includeSpecials), + downloadLinksInitialized: Value(downloadLinksInitialized), ); } @@ -3613,6 +3652,9 @@ class SyncRuleItem extends DataClass implements Insertable { mediaIndex: serializer.fromJson(json['mediaIndex']), downloadFilter: serializer.fromJson(json['downloadFilter']), includeSpecials: serializer.fromJson(json['includeSpecials']), + downloadLinksInitialized: serializer.fromJson( + json['downloadLinksInitialized'], + ), ); } @override @@ -3632,6 +3674,9 @@ class SyncRuleItem extends DataClass implements Insertable { 'mediaIndex': serializer.toJson(mediaIndex), 'downloadFilter': serializer.toJson(downloadFilter), 'includeSpecials': serializer.toJson(includeSpecials), + 'downloadLinksInitialized': serializer.toJson( + downloadLinksInitialized, + ), }; } @@ -3649,6 +3694,7 @@ class SyncRuleItem extends DataClass implements Insertable { int? mediaIndex, String? downloadFilter, bool? includeSpecials, + bool? downloadLinksInitialized, }) => SyncRuleItem( id: id ?? this.id, profileId: profileId ?? this.profileId, @@ -3665,6 +3711,8 @@ class SyncRuleItem extends DataClass implements Insertable { mediaIndex: mediaIndex ?? this.mediaIndex, downloadFilter: downloadFilter ?? this.downloadFilter, includeSpecials: includeSpecials ?? this.includeSpecials, + downloadLinksInitialized: + downloadLinksInitialized ?? this.downloadLinksInitialized, ); SyncRuleItem copyWithCompanion(SyncRulesCompanion data) { return SyncRuleItem( @@ -3693,6 +3741,9 @@ class SyncRuleItem extends DataClass implements Insertable { includeSpecials: data.includeSpecials.present ? data.includeSpecials.value : this.includeSpecials, + downloadLinksInitialized: data.downloadLinksInitialized.present + ? data.downloadLinksInitialized.value + : this.downloadLinksInitialized, ); } @@ -3711,7 +3762,8 @@ class SyncRuleItem extends DataClass implements Insertable { ..write('lastExecutedAt: $lastExecutedAt, ') ..write('mediaIndex: $mediaIndex, ') ..write('downloadFilter: $downloadFilter, ') - ..write('includeSpecials: $includeSpecials') + ..write('includeSpecials: $includeSpecials, ') + ..write('downloadLinksInitialized: $downloadLinksInitialized') ..write(')')) .toString(); } @@ -3731,6 +3783,7 @@ class SyncRuleItem extends DataClass implements Insertable { mediaIndex, downloadFilter, includeSpecials, + downloadLinksInitialized, ); @override bool operator ==(Object other) => @@ -3748,7 +3801,8 @@ class SyncRuleItem extends DataClass implements Insertable { other.lastExecutedAt == this.lastExecutedAt && other.mediaIndex == this.mediaIndex && other.downloadFilter == this.downloadFilter && - other.includeSpecials == this.includeSpecials); + other.includeSpecials == this.includeSpecials && + other.downloadLinksInitialized == this.downloadLinksInitialized); } class SyncRulesCompanion extends UpdateCompanion { @@ -3765,6 +3819,7 @@ class SyncRulesCompanion extends UpdateCompanion { final Value mediaIndex; final Value downloadFilter; final Value includeSpecials; + final Value downloadLinksInitialized; const SyncRulesCompanion({ this.id = const Value.absent(), this.profileId = const Value.absent(), @@ -3779,6 +3834,7 @@ class SyncRulesCompanion extends UpdateCompanion { this.mediaIndex = const Value.absent(), this.downloadFilter = const Value.absent(), this.includeSpecials = const Value.absent(), + this.downloadLinksInitialized = const Value.absent(), }); SyncRulesCompanion.insert({ this.id = const Value.absent(), @@ -3794,6 +3850,7 @@ class SyncRulesCompanion extends UpdateCompanion { this.mediaIndex = const Value.absent(), this.downloadFilter = const Value.absent(), this.includeSpecials = const Value.absent(), + this.downloadLinksInitialized = const Value.absent(), }) : serverId = Value(serverId), ratingKey = Value(ratingKey), globalKey = Value(globalKey), @@ -3814,6 +3871,7 @@ class SyncRulesCompanion extends UpdateCompanion { Expression? mediaIndex, Expression? downloadFilter, Expression? includeSpecials, + Expression? downloadLinksInitialized, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -3829,6 +3887,8 @@ class SyncRulesCompanion extends UpdateCompanion { if (mediaIndex != null) 'media_index': mediaIndex, if (downloadFilter != null) 'download_filter': downloadFilter, if (includeSpecials != null) 'include_specials': includeSpecials, + if (downloadLinksInitialized != null) + 'download_links_initialized': downloadLinksInitialized, }); } @@ -3846,6 +3906,7 @@ class SyncRulesCompanion extends UpdateCompanion { Value? mediaIndex, Value? downloadFilter, Value? includeSpecials, + Value? downloadLinksInitialized, }) { return SyncRulesCompanion( id: id ?? this.id, @@ -3861,6 +3922,8 @@ class SyncRulesCompanion extends UpdateCompanion { mediaIndex: mediaIndex ?? this.mediaIndex, downloadFilter: downloadFilter ?? this.downloadFilter, includeSpecials: includeSpecials ?? this.includeSpecials, + downloadLinksInitialized: + downloadLinksInitialized ?? this.downloadLinksInitialized, ); } @@ -3906,6 +3969,11 @@ class SyncRulesCompanion extends UpdateCompanion { if (includeSpecials.present) { map['include_specials'] = Variable(includeSpecials.value); } + if (downloadLinksInitialized.present) { + map['download_links_initialized'] = Variable( + downloadLinksInitialized.value, + ); + } return map; } @@ -3924,7 +3992,296 @@ class SyncRulesCompanion extends UpdateCompanion { ..write('lastExecutedAt: $lastExecutedAt, ') ..write('mediaIndex: $mediaIndex, ') ..write('downloadFilter: $downloadFilter, ') - ..write('includeSpecials: $includeSpecials') + ..write('includeSpecials: $includeSpecials, ') + ..write('downloadLinksInitialized: $downloadLinksInitialized') + ..write(')')) + .toString(); + } +} + +class $SyncRuleDownloadsTable extends SyncRuleDownloads + with TableInfo<$SyncRuleDownloadsTable, SyncRuleDownloadItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncRuleDownloadsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _syncRuleIdMeta = const VerificationMeta( + 'syncRuleId', + ); + @override + late final GeneratedColumn syncRuleId = GeneratedColumn( + 'sync_rule_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES sync_rules (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _profileIdMeta = const VerificationMeta( + 'profileId', + ); + @override + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _downloadGlobalKeyMeta = const VerificationMeta( + 'downloadGlobalKey', + ); + @override + late final GeneratedColumn downloadGlobalKey = + GeneratedColumn( + 'download_global_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + syncRuleId, + profileId, + downloadGlobalKey, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_rule_downloads'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('sync_rule_id')) { + context.handle( + _syncRuleIdMeta, + syncRuleId.isAcceptableOrUnknown( + data['sync_rule_id']!, + _syncRuleIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_syncRuleIdMeta); + } + if (data.containsKey('profile_id')) { + context.handle( + _profileIdMeta, + profileId.isAcceptableOrUnknown(data['profile_id']!, _profileIdMeta), + ); + } else if (isInserting) { + context.missing(_profileIdMeta); + } + if (data.containsKey('download_global_key')) { + context.handle( + _downloadGlobalKeyMeta, + downloadGlobalKey.isAcceptableOrUnknown( + data['download_global_key']!, + _downloadGlobalKeyMeta, + ), + ); + } else if (isInserting) { + context.missing(_downloadGlobalKeyMeta); + } + return context; + } + + @override + Set get $primaryKey => {syncRuleId, downloadGlobalKey}; + @override + SyncRuleDownloadItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncRuleDownloadItem( + syncRuleId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sync_rule_id'], + )!, + profileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}profile_id'], + )!, + downloadGlobalKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}download_global_key'], + )!, + ); + } + + @override + $SyncRuleDownloadsTable createAlias(String alias) { + return $SyncRuleDownloadsTable(attachedDatabase, alias); + } +} + +class SyncRuleDownloadItem extends DataClass + implements Insertable { + final int syncRuleId; + final String profileId; + final String downloadGlobalKey; + const SyncRuleDownloadItem({ + required this.syncRuleId, + required this.profileId, + required this.downloadGlobalKey, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['sync_rule_id'] = Variable(syncRuleId); + map['profile_id'] = Variable(profileId); + map['download_global_key'] = Variable(downloadGlobalKey); + return map; + } + + SyncRuleDownloadsCompanion toCompanion(bool nullToAbsent) { + return SyncRuleDownloadsCompanion( + syncRuleId: Value(syncRuleId), + profileId: Value(profileId), + downloadGlobalKey: Value(downloadGlobalKey), + ); + } + + factory SyncRuleDownloadItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncRuleDownloadItem( + syncRuleId: serializer.fromJson(json['syncRuleId']), + profileId: serializer.fromJson(json['profileId']), + downloadGlobalKey: serializer.fromJson(json['downloadGlobalKey']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'syncRuleId': serializer.toJson(syncRuleId), + 'profileId': serializer.toJson(profileId), + 'downloadGlobalKey': serializer.toJson(downloadGlobalKey), + }; + } + + SyncRuleDownloadItem copyWith({ + int? syncRuleId, + String? profileId, + String? downloadGlobalKey, + }) => SyncRuleDownloadItem( + syncRuleId: syncRuleId ?? this.syncRuleId, + profileId: profileId ?? this.profileId, + downloadGlobalKey: downloadGlobalKey ?? this.downloadGlobalKey, + ); + SyncRuleDownloadItem copyWithCompanion(SyncRuleDownloadsCompanion data) { + return SyncRuleDownloadItem( + syncRuleId: data.syncRuleId.present + ? data.syncRuleId.value + : this.syncRuleId, + profileId: data.profileId.present ? data.profileId.value : this.profileId, + downloadGlobalKey: data.downloadGlobalKey.present + ? data.downloadGlobalKey.value + : this.downloadGlobalKey, + ); + } + + @override + String toString() { + return (StringBuffer('SyncRuleDownloadItem(') + ..write('syncRuleId: $syncRuleId, ') + ..write('profileId: $profileId, ') + ..write('downloadGlobalKey: $downloadGlobalKey') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(syncRuleId, profileId, downloadGlobalKey); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncRuleDownloadItem && + other.syncRuleId == this.syncRuleId && + other.profileId == this.profileId && + other.downloadGlobalKey == this.downloadGlobalKey); +} + +class SyncRuleDownloadsCompanion extends UpdateCompanion { + final Value syncRuleId; + final Value profileId; + final Value downloadGlobalKey; + final Value rowid; + const SyncRuleDownloadsCompanion({ + this.syncRuleId = const Value.absent(), + this.profileId = const Value.absent(), + this.downloadGlobalKey = const Value.absent(), + this.rowid = const Value.absent(), + }); + SyncRuleDownloadsCompanion.insert({ + required int syncRuleId, + required String profileId, + required String downloadGlobalKey, + this.rowid = const Value.absent(), + }) : syncRuleId = Value(syncRuleId), + profileId = Value(profileId), + downloadGlobalKey = Value(downloadGlobalKey); + static Insertable custom({ + Expression? syncRuleId, + Expression? profileId, + Expression? downloadGlobalKey, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (syncRuleId != null) 'sync_rule_id': syncRuleId, + if (profileId != null) 'profile_id': profileId, + if (downloadGlobalKey != null) 'download_global_key': downloadGlobalKey, + if (rowid != null) 'rowid': rowid, + }); + } + + SyncRuleDownloadsCompanion copyWith({ + Value? syncRuleId, + Value? profileId, + Value? downloadGlobalKey, + Value? rowid, + }) { + return SyncRuleDownloadsCompanion( + syncRuleId: syncRuleId ?? this.syncRuleId, + profileId: profileId ?? this.profileId, + downloadGlobalKey: downloadGlobalKey ?? this.downloadGlobalKey, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (syncRuleId.present) { + map['sync_rule_id'] = Variable(syncRuleId.value); + } + if (profileId.present) { + map['profile_id'] = Variable(profileId.value); + } + if (downloadGlobalKey.present) { + map['download_global_key'] = Variable(downloadGlobalKey.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncRuleDownloadsCompanion(') + ..write('syncRuleId: $syncRuleId, ') + ..write('profileId: $profileId, ') + ..write('downloadGlobalKey: $downloadGlobalKey, ') + ..write('rowid: $rowid') ..write(')')) .toString(); } @@ -5474,6 +5831,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $OfflineWatchProgressTable offlineWatchProgress = $OfflineWatchProgressTable(this); late final $SyncRulesTable syncRules = $SyncRulesTable(this); + late final $SyncRuleDownloadsTable syncRuleDownloads = + $SyncRuleDownloadsTable(this); late final $ConnectionsTable connections = $ConnectionsTable(this); late final $ProfilesTable profiles = $ProfilesTable(this); late final $ProfileConnectionsTable profileConnections = @@ -5514,6 +5873,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { 'idx_sync_rules_profile', 'CREATE INDEX idx_sync_rules_profile ON sync_rules (profile_id)', ); + late final Index idxSyncRuleDownloadsProfileKey = Index( + 'idx_sync_rule_downloads_profile_key', + 'CREATE INDEX idx_sync_rule_downloads_profile_key ON sync_rule_downloads (profile_id, download_global_key)', + ); late final Index idxConnectionsKind = Index( 'idx_connections_kind', 'CREATE INDEX idx_connections_kind ON connections (kind)', @@ -5541,6 +5904,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { apiCache, offlineWatchProgress, syncRules, + syncRuleDownloads, connections, profiles, profileConnections, @@ -5553,6 +5917,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { idxOfflineWatchProgressServer, idxOfflineWatchProgressProfile, idxSyncRulesProfile, + idxSyncRuleDownloadsProfileKey, idxConnectionsKind, idxProfilesKind, idxProfileConnectionsConnectionId, @@ -5560,6 +5925,13 @@ abstract class _$AppDatabase extends GeneratedDatabase { ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'sync_rules', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('sync_rule_downloads', kind: UpdateKind.delete)], + ), WritePropagation( on: TableUpdateQuery.onTableName( 'connections', @@ -7120,6 +7492,7 @@ typedef $$SyncRulesTableCreateCompanionBuilder = Value mediaIndex, Value downloadFilter, Value includeSpecials, + Value downloadLinksInitialized, }); typedef $$SyncRulesTableUpdateCompanionBuilder = SyncRulesCompanion Function({ @@ -7136,8 +7509,38 @@ typedef $$SyncRulesTableUpdateCompanionBuilder = Value mediaIndex, Value downloadFilter, Value includeSpecials, + Value downloadLinksInitialized, }); +final class $$SyncRulesTableReferences + extends BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem> { + $$SyncRulesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey< + $SyncRuleDownloadsTable, + List + > + _syncRuleDownloadsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.syncRuleDownloads, + aliasName: 'sync_rules__id__sync_rule_downloads__sync_rule_id', + ); + + $$SyncRuleDownloadsTableProcessedTableManager get syncRuleDownloadsRefs { + final manager = $$SyncRuleDownloadsTableTableManager( + $_db, + $_db.syncRuleDownloads, + ).filter((f) => f.syncRuleId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _syncRuleDownloadsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + class $$SyncRulesTableFilterComposer extends Composer<_$AppDatabase, $SyncRulesTable> { $$SyncRulesTableFilterComposer({ @@ -7211,6 +7614,36 @@ class $$SyncRulesTableFilterComposer column: $table.includeSpecials, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get downloadLinksInitialized => $composableBuilder( + column: $table.downloadLinksInitialized, + builder: (column) => ColumnFilters(column), + ); + + Expression syncRuleDownloadsRefs( + Expression Function($$SyncRuleDownloadsTableFilterComposer f) f, + ) { + final $$SyncRuleDownloadsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncRuleDownloads, + getReferencedColumn: (t) => t.syncRuleId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncRuleDownloadsTableFilterComposer( + $db: $db, + $table: $db.syncRuleDownloads, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$SyncRulesTableOrderingComposer @@ -7286,6 +7719,11 @@ class $$SyncRulesTableOrderingComposer column: $table.includeSpecials, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get downloadLinksInitialized => $composableBuilder( + column: $table.downloadLinksInitialized, + builder: (column) => ColumnOrderings(column), + ); } class $$SyncRulesTableAnnotationComposer @@ -7347,6 +7785,37 @@ class $$SyncRulesTableAnnotationComposer column: $table.includeSpecials, builder: (column) => column, ); + + GeneratedColumn get downloadLinksInitialized => $composableBuilder( + column: $table.downloadLinksInitialized, + builder: (column) => column, + ); + + Expression syncRuleDownloadsRefs( + Expression Function($$SyncRuleDownloadsTableAnnotationComposer a) f, + ) { + final $$SyncRuleDownloadsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncRuleDownloads, + getReferencedColumn: (t) => t.syncRuleId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncRuleDownloadsTableAnnotationComposer( + $db: $db, + $table: $db.syncRuleDownloads, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$SyncRulesTableTableManager @@ -7360,12 +7829,9 @@ class $$SyncRulesTableTableManager $$SyncRulesTableAnnotationComposer, $$SyncRulesTableCreateCompanionBuilder, $$SyncRulesTableUpdateCompanionBuilder, - ( - SyncRuleItem, - BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>, - ), + (SyncRuleItem, $$SyncRulesTableReferences), SyncRuleItem, - PrefetchHooks Function() + PrefetchHooks Function({bool syncRuleDownloadsRefs}) > { $$SyncRulesTableTableManager(_$AppDatabase db, $SyncRulesTable table) : super( @@ -7393,6 +7859,7 @@ class $$SyncRulesTableTableManager Value mediaIndex = const Value.absent(), Value downloadFilter = const Value.absent(), Value includeSpecials = const Value.absent(), + Value downloadLinksInitialized = const Value.absent(), }) => SyncRulesCompanion( id: id, profileId: profileId, @@ -7407,6 +7874,7 @@ class $$SyncRulesTableTableManager mediaIndex: mediaIndex, downloadFilter: downloadFilter, includeSpecials: includeSpecials, + downloadLinksInitialized: downloadLinksInitialized, ), createCompanionCallback: ({ @@ -7423,6 +7891,7 @@ class $$SyncRulesTableTableManager Value mediaIndex = const Value.absent(), Value downloadFilter = const Value.absent(), Value includeSpecials = const Value.absent(), + Value downloadLinksInitialized = const Value.absent(), }) => SyncRulesCompanion.insert( id: id, profileId: profileId, @@ -7437,11 +7906,48 @@ class $$SyncRulesTableTableManager mediaIndex: mediaIndex, downloadFilter: downloadFilter, includeSpecials: includeSpecials, + downloadLinksInitialized: downloadLinksInitialized, ), withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .map( + (e) => ( + e.readTable(table), + $$SyncRulesTableReferences(db, table, e), + ), + ) .toList(), - prefetchHooksCallback: null, + prefetchHooksCallback: ({syncRuleDownloadsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (syncRuleDownloadsRefs) db.syncRuleDownloads, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (syncRuleDownloadsRefs) + await $_getPrefetchedData< + SyncRuleItem, + $SyncRulesTable, + SyncRuleDownloadItem + >( + currentTable: table, + referencedTable: $$SyncRulesTableReferences + ._syncRuleDownloadsRefsTable(db), + managerFromTypedResult: (p0) => + $$SyncRulesTableReferences( + db, + table, + p0, + ).syncRuleDownloadsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.syncRuleId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, ), ); } @@ -7456,12 +7962,306 @@ typedef $$SyncRulesTableProcessedTableManager = $$SyncRulesTableAnnotationComposer, $$SyncRulesTableCreateCompanionBuilder, $$SyncRulesTableUpdateCompanionBuilder, - ( - SyncRuleItem, - BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>, - ), + (SyncRuleItem, $$SyncRulesTableReferences), SyncRuleItem, - PrefetchHooks Function() + PrefetchHooks Function({bool syncRuleDownloadsRefs}) + >; +typedef $$SyncRuleDownloadsTableCreateCompanionBuilder = + SyncRuleDownloadsCompanion Function({ + required int syncRuleId, + required String profileId, + required String downloadGlobalKey, + Value rowid, + }); +typedef $$SyncRuleDownloadsTableUpdateCompanionBuilder = + SyncRuleDownloadsCompanion Function({ + Value syncRuleId, + Value profileId, + Value downloadGlobalKey, + Value rowid, + }); + +final class $$SyncRuleDownloadsTableReferences + extends + BaseReferences< + _$AppDatabase, + $SyncRuleDownloadsTable, + SyncRuleDownloadItem + > { + $$SyncRuleDownloadsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $SyncRulesTable _syncRuleIdTable(_$AppDatabase db) => db.syncRules + .createAlias('sync_rule_downloads__sync_rule_id__sync_rules__id'); + + $$SyncRulesTableProcessedTableManager get syncRuleId { + final $_column = $_itemColumn('sync_rule_id')!; + + final manager = $$SyncRulesTableTableManager( + $_db, + $_db.syncRules, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_syncRuleIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$SyncRuleDownloadsTableFilterComposer + extends Composer<_$AppDatabase, $SyncRuleDownloadsTable> { + $$SyncRuleDownloadsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get downloadGlobalKey => $composableBuilder( + column: $table.downloadGlobalKey, + builder: (column) => ColumnFilters(column), + ); + + $$SyncRulesTableFilterComposer get syncRuleId { + final $$SyncRulesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.syncRuleId, + referencedTable: $db.syncRules, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncRulesTableFilterComposer( + $db: $db, + $table: $db.syncRules, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SyncRuleDownloadsTableOrderingComposer + extends Composer<_$AppDatabase, $SyncRuleDownloadsTable> { + $$SyncRuleDownloadsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get downloadGlobalKey => $composableBuilder( + column: $table.downloadGlobalKey, + builder: (column) => ColumnOrderings(column), + ); + + $$SyncRulesTableOrderingComposer get syncRuleId { + final $$SyncRulesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.syncRuleId, + referencedTable: $db.syncRules, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncRulesTableOrderingComposer( + $db: $db, + $table: $db.syncRules, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SyncRuleDownloadsTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncRuleDownloadsTable> { + $$SyncRuleDownloadsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get profileId => + $composableBuilder(column: $table.profileId, builder: (column) => column); + + GeneratedColumn get downloadGlobalKey => $composableBuilder( + column: $table.downloadGlobalKey, + builder: (column) => column, + ); + + $$SyncRulesTableAnnotationComposer get syncRuleId { + final $$SyncRulesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.syncRuleId, + referencedTable: $db.syncRules, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncRulesTableAnnotationComposer( + $db: $db, + $table: $db.syncRules, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SyncRuleDownloadsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SyncRuleDownloadsTable, + SyncRuleDownloadItem, + $$SyncRuleDownloadsTableFilterComposer, + $$SyncRuleDownloadsTableOrderingComposer, + $$SyncRuleDownloadsTableAnnotationComposer, + $$SyncRuleDownloadsTableCreateCompanionBuilder, + $$SyncRuleDownloadsTableUpdateCompanionBuilder, + (SyncRuleDownloadItem, $$SyncRuleDownloadsTableReferences), + SyncRuleDownloadItem, + PrefetchHooks Function({bool syncRuleId}) + > { + $$SyncRuleDownloadsTableTableManager( + _$AppDatabase db, + $SyncRuleDownloadsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncRuleDownloadsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncRuleDownloadsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncRuleDownloadsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value syncRuleId = const Value.absent(), + Value profileId = const Value.absent(), + Value downloadGlobalKey = const Value.absent(), + Value rowid = const Value.absent(), + }) => SyncRuleDownloadsCompanion( + syncRuleId: syncRuleId, + profileId: profileId, + downloadGlobalKey: downloadGlobalKey, + rowid: rowid, + ), + createCompanionCallback: + ({ + required int syncRuleId, + required String profileId, + required String downloadGlobalKey, + Value rowid = const Value.absent(), + }) => SyncRuleDownloadsCompanion.insert( + syncRuleId: syncRuleId, + profileId: profileId, + downloadGlobalKey: downloadGlobalKey, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$SyncRuleDownloadsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({syncRuleId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (syncRuleId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.syncRuleId, + referencedTable: + $$SyncRuleDownloadsTableReferences + ._syncRuleIdTable(db), + referencedColumn: + $$SyncRuleDownloadsTableReferences + ._syncRuleIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$SyncRuleDownloadsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SyncRuleDownloadsTable, + SyncRuleDownloadItem, + $$SyncRuleDownloadsTableFilterComposer, + $$SyncRuleDownloadsTableOrderingComposer, + $$SyncRuleDownloadsTableAnnotationComposer, + $$SyncRuleDownloadsTableCreateCompanionBuilder, + $$SyncRuleDownloadsTableUpdateCompanionBuilder, + (SyncRuleDownloadItem, $$SyncRuleDownloadsTableReferences), + SyncRuleDownloadItem, + PrefetchHooks Function({bool syncRuleId}) >; typedef $$ConnectionsTableCreateCompanionBuilder = ConnectionsCompanion Function({ @@ -8475,6 +9275,8 @@ class $AppDatabaseManager { $$OfflineWatchProgressTableTableManager(_db, _db.offlineWatchProgress); $$SyncRulesTableTableManager get syncRules => $$SyncRulesTableTableManager(_db, _db.syncRules); + $$SyncRuleDownloadsTableTableManager get syncRuleDownloads => + $$SyncRuleDownloadsTableTableManager(_db, _db.syncRuleDownloads); $$ConnectionsTableTableManager get connections => $$ConnectionsTableTableManager(_db, _db.connections); $$ProfilesTableTableManager get profiles => diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index ea0def15..d36387e2 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -51,8 +51,13 @@ extension DownloadDatabaseOperations on AppDatabase { ); } - Future removeDownloadOwner({required String profileId, required String globalKey}) async { - await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go(); + Future removeDownloadOwner({required String profileId, required String globalKey}) { + return transaction(() async { + await (delete( + syncRuleDownloads, + )..where((t) => t.profileId.equals(profileId) & t.downloadGlobalKey.equals(globalKey))).go(); + await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go(); + }); } /// Removes one owner from a shared download while keeping an incomplete @@ -102,8 +107,12 @@ extension DownloadDatabaseOperations on AppDatabase { )..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull(); } - Future clearAllDownloadOwners() async { - await delete(downloadOwners).go(); + Future clearAllDownloadOwners() { + return transaction(() async { + await delete(syncRuleDownloads).go(); + await update(syncRules).write(const SyncRulesCompanion(downloadLinksInitialized: Value(false))); + await delete(downloadOwners).go(); + }); } Future> getDownloadOwnerKeysForProfile(String profileId) async { @@ -620,6 +629,7 @@ extension DownloadDatabaseOperations on AppDatabase { late String? safRootUri; await transaction(() async { safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri; + await (delete(syncRuleDownloads)..where((t) => t.downloadGlobalKey.equals(globalKey))).go(); await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); diff --git a/lib/database/tables.dart b/lib/database/tables.dart index a144f17c..94fd76d5 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -105,6 +105,27 @@ class SyncRules extends Table { IntColumn get mediaIndex => integer().withDefault(const Constant(0))(); 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. + BoolColumn get downloadLinksInitialized => boolean().withDefault(const Constant(false))(); +} + +/// Downloads covered by a sync rule for one profile. +/// +/// Links are retained when list membership changes so removing a rule can +/// clean up items it previously synced without re-fetching the list. A +/// download may be linked to multiple rules. +@DataClassName('SyncRuleDownloadItem') +@TableIndex(name: 'idx_sync_rule_downloads_profile_key', columns: {#profileId, #downloadGlobalKey}) +class SyncRuleDownloads extends Table { + IntColumn get syncRuleId => integer().references(SyncRules, #id, onDelete: KeyAction.cascade)(); + TextColumn get profileId => text()(); + TextColumn get downloadGlobalKey => text()(); + + @override + Set get primaryKey => {syncRuleId, downloadGlobalKey}; } /// Persisted media-server connections. diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index d677123d..4ecc9ecd 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Редактирай правило за синхронизация", "removeSyncRule": "Премахни правило за синхронизация", "removeSyncRuleConfirm": "Да се спре ли синхронизацията за \"${title}\"? Изтеглените епизоди ще останат.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Правилото за синхронизация е създадено — запазват се ${count} негледани епизода", "syncRuleUpdated": "Правилото за синхронизация е обновено", "syncRuleRemoved": "Правилото за синхронизация е премахнато", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "Синхронизирани са ${count} нови епизода за ${title}", "activeSyncRules": "Правила за синхронизация", "noSyncRules": "Няма правила за синхронизация", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index e5fbca34..cca6c66f 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Rediger synkroniseringsregel", "removeSyncRule": "Fjern synkroniseringsregel", "removeSyncRuleConfirm": "Stop synkronisering af \"${title}\"? Downloadede episoder beholdes.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Synkroniseringsregel oprettet — beholder ${count} usete episoder", "syncRuleUpdated": "Synkroniseringsregel opdateret", "syncRuleRemoved": "Synkroniseringsregel fjernet", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "Synkroniserede ${count} nye episoder for ${title}", "activeSyncRules": "Synkroniseringsregler", "noSyncRules": "Ingen synkroniseringsregler", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4c75a897..8934a362 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Synchronisierungsregel bearbeiten", "removeSyncRule": "Synchronisierungsregel entfernen", "removeSyncRuleConfirm": "Synchronisierung von „${title}“ beenden? Heruntergeladene Episoden werden behalten.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Synchronisierungsregel erstellt – ${count} ungesehene Episoden werden behalten", "syncRuleUpdated": "Synchronisierungsregel aktualisiert", "syncRuleRemoved": "Synchronisierungsregel entfernt", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} neue Episoden für ${title} synchronisiert", "activeSyncRules": "Synchronisierungsregeln", "noSyncRules": "Keine Synchronisierungsregeln", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 3c52d1a5..2d48ad84 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Edit sync rule", "removeSyncRule": "Remove sync rule", "removeSyncRuleConfirm": "Stop syncing \"${title}\"? Downloaded episodes will be kept.", + "removeListSyncRuleConfirm": "Stop syncing \"${title}\"?", + "deleteSyncRuleDownloads": "Also delete associated downloads", + "deleteSyncRuleDownloadsDescription": "Downloads used by another sync rule or profile will be kept.", "syncRuleCreated": "Sync rule created — keeping ${count} unwatched episodes", "syncRuleUpdated": "Sync rule updated", "syncRuleRemoved": "Sync rule removed", + "syncRuleAndDownloadsRemoved": "Sync rule and associated downloads removed", + "syncRuleCleanupBusy": "Sync rules are currently updating. Try again in a moment.", + "syncRuleCleanupUnavailable": "Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.", "syncedNewEpisodes": "Synced ${count} new episodes for ${title}", "activeSyncRules": "Sync rules", "noSyncRules": "No sync rules", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 416c4dae..3273139f 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Editar regla de sincronización", "removeSyncRule": "Eliminar regla de sincronización", "removeSyncRuleConfirm": "¿Dejar de sincronizar \"${title}\"? Los episodios descargados se conservarán.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Regla de sincronización creada — se conservarán ${count} episodios no vistos", "syncRuleUpdated": "Regla de sincronización actualizada", "syncRuleRemoved": "Regla de sincronización eliminada", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} nuevos episodios sincronizados para ${title}", "activeSyncRules": "Reglas de sincronización", "noSyncRules": "Sin reglas de sincronización", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 04303fc2..61b000e2 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Modifier la règle de synchronisation", "removeSyncRule": "Supprimer la règle de synchronisation", "removeSyncRuleConfirm": "Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Règle de synchronisation créée — ${count} épisodes non vus conservés", "syncRuleUpdated": "Règle de synchronisation mise à jour", "syncRuleRemoved": "Règle de synchronisation supprimée", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} nouveaux épisodes synchronisés pour ${title}", "activeSyncRules": "Règles de synchronisation", "noSyncRules": "Aucune règle de synchronisation", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index cc01fc44..0a811752 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Szinkronizálási szabály szerkesztése", "removeSyncRule": "Szinkronizálási szabály eltávolítása", "removeSyncRuleConfirm": "Leállítod a(z) \"${title}\" szinkronizálását? A letöltött epizódok megmaradnak.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Szinkronizálási szabály létrehozva — ${count} nem látott epizód megtartása", "syncRuleUpdated": "Szinkronizálási szabály frissítve", "syncRuleRemoved": "Szinkronizálási szabály eltávolítva", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} új epizód szinkronizálva a következőhöz: ${title}", "activeSyncRules": "Szinkronizálási szabályok", "noSyncRules": "Nincsenek szinkronizálási szabályok", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 98a08ac9..82f7dcd4 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Modifica regola di sincronizzazione", "removeSyncRule": "Rimuovi regola di sincronizzazione", "removeSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"? Gli episodi scaricati verranno mantenuti.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Regola di sincronizzazione creata — ${count} episodi non visti mantenuti", "syncRuleUpdated": "Regola di sincronizzazione aggiornata", "syncRuleRemoved": "Regola di sincronizzazione rimossa", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} nuovi episodi sincronizzati per ${title}", "activeSyncRules": "Regole di sincronizzazione", "noSyncRules": "Nessuna regola di sincronizzazione", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 1cbe1bc2..2bd5f60e 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1188,9 +1188,15 @@ "editSyncRule": "同期ルールを編集", "removeSyncRule": "同期ルールを削除", "removeSyncRuleConfirm": "「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "同期ルールを作成しました — 未視聴のエピソードを${count}件保持", "syncRuleUpdated": "同期ルールを更新しました", "syncRuleRemoved": "同期ルールを削除しました", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${title}の新しいエピソードを${count}件同期しました", "activeSyncRules": "同期ルール", "noSyncRules": "同期ルールなし", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index bdf4cf59..45e42e20 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1188,9 +1188,15 @@ "editSyncRule": "동기화 규칙 편집", "removeSyncRule": "동기화 규칙 제거", "removeSyncRuleConfirm": "\"${title}\" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지", "syncRuleUpdated": "동기화 규칙 업데이트됨", "syncRuleRemoved": "동기화 규칙 제거됨", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${title}의 새 에피소드 ${count}개 동기화됨", "activeSyncRules": "동기화 규칙", "noSyncRules": "동기화 규칙 없음", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 27454aff..465742f9 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Rediger synkroniseringsregel", "removeSyncRule": "Fjern synkroniseringsregel", "removeSyncRuleConfirm": "Slutte å synkronisere \"${title}\"? Nedlastede episoder beholdes.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Synkroniseringsregel opprettet — beholder ${count} usette episoder", "syncRuleUpdated": "Synkroniseringsregel oppdatert", "syncRuleRemoved": "Synkroniseringsregel fjernet", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "Synkroniserte ${count} nye episoder for ${title}", "activeSyncRules": "Synkroniseringsregler", "noSyncRules": "Ingen synkroniseringsregler", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index e019a451..570207bf 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Synchronisatieregel bewerken", "removeSyncRule": "Synchronisatieregel verwijderen", "removeSyncRuleConfirm": "Synchronisatie van \"${title}\" stoppen? Gedownloade afleveringen worden behouden.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden", "syncRuleUpdated": "Synchronisatieregel bijgewerkt", "syncRuleRemoved": "Synchronisatieregel verwijderd", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} nieuwe afleveringen gesynchroniseerd voor ${title}", "activeSyncRules": "Synchronisatieregels", "noSyncRules": "Geen synchronisatieregels", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 31fdd24e..e8d9061c 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1197,9 +1197,15 @@ "editSyncRule": "Edytuj regułę synchronizacji", "removeSyncRule": "Usuń regułę synchronizacji", "removeSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"? Pobrane odcinki zostaną zachowane.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków", "syncRuleUpdated": "Reguła synchronizacji zaktualizowana", "syncRuleRemoved": "Reguła synchronizacji usunięta", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "Zsynchronizowano ${count} nowych odcinków dla ${title}", "activeSyncRules": "Reguły synchronizacji", "noSyncRules": "Brak reguł synchronizacji", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 05833897..03384064 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Editar regra de sincronização", "removeSyncRule": "Remover regra de sincronização", "removeSyncRuleConfirm": "Parar de sincronizar \"${title}\"? Os episódios baixados serão mantidos.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Regra de sincronização criada — mantendo ${count} episódios não assistidos", "syncRuleUpdated": "Regra de sincronização atualizada", "syncRuleRemoved": "Regra de sincronização removida", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "${count} novos episódios sincronizados para ${title}", "activeSyncRules": "Regras de sincronização", "noSyncRules": "Nenhuma regra de sincronização", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index c051362c..9c428559 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1197,9 +1197,15 @@ "editSyncRule": "Редактировать правило синхронизации", "removeSyncRule": "Удалить правило синхронизации", "removeSyncRuleConfirm": "Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Правило синхронизации создано — хранится ${count} непросмотренных эпизодов", "syncRuleUpdated": "Правило синхронизации обновлено", "syncRuleRemoved": "Правило синхронизации удалено", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "Синхронизировано ${count} новых эпизодов для ${title}", "activeSyncRules": "Правила синхронизации", "noSyncRules": "Нет правил синхронизации", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index ce68aa71..bde60d3e 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 18 -/// Strings: 26501 (1472 per locale) +/// Strings: 26507 (1472 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 4a0b1273..a0342320 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3450,6 +3450,15 @@ class Translations$downloads$en { /// en: 'Stop syncing "${title}"? Downloaded episodes will be kept.' String removeSyncRuleConfirm({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.'; + /// en: 'Stop syncing "${title}"?' + String removeListSyncRuleConfirm({required Object title}) => 'Stop syncing "${title}"?'; + + /// en: 'Also delete associated downloads' + String get deleteSyncRuleDownloads => 'Also delete associated downloads'; + + /// en: 'Downloads used by another sync rule or profile will be kept.' + String get deleteSyncRuleDownloadsDescription => 'Downloads used by another sync rule or profile will be kept.'; + /// en: 'Sync rule created — keeping ${count} unwatched episodes' String syncRuleCreated({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes'; @@ -3459,6 +3468,15 @@ class Translations$downloads$en { /// en: 'Sync rule removed' String get syncRuleRemoved => 'Sync rule removed'; + /// en: 'Sync rule and associated downloads removed' + String get syncRuleAndDownloadsRemoved => 'Sync rule and associated downloads removed'; + + /// en: 'Sync rules are currently updating. Try again in a moment.' + String get syncRuleCleanupBusy => 'Sync rules are currently updating. Try again in a moment.'; + + /// en: 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.' + String get syncRuleCleanupUnavailable => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.'; + /// en: 'Synced ${count} new episodes for ${title}' String syncedNewEpisodes({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}'; @@ -6245,9 +6263,15 @@ extension on Translations { 'downloads.editSyncRule' => 'Edit sync rule', 'downloads.removeSyncRule' => 'Remove sync rule', 'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.', + 'downloads.removeListSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"?', + 'downloads.deleteSyncRuleDownloads' => 'Also delete associated downloads', + 'downloads.deleteSyncRuleDownloadsDescription' => 'Downloads used by another sync rule or profile will be kept.', 'downloads.syncRuleCreated' => ({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes', 'downloads.syncRuleUpdated' => 'Sync rule updated', 'downloads.syncRuleRemoved' => 'Sync rule removed', + 'downloads.syncRuleAndDownloadsRemoved' => 'Sync rule and associated downloads removed', + 'downloads.syncRuleCleanupBusy' => 'Sync rules are currently updating. Try again in a moment.', + 'downloads.syncRuleCleanupUnavailable' => 'Associated downloads could not be identified safely. Reconnect the server and try again, or remove the rule without deleting downloads.', 'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}', 'downloads.activeSyncRules' => 'Sync rules', 'downloads.noSyncRules' => 'No sync rules', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index f4a0ffb4..d5d44cad 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1191,9 +1191,15 @@ "editSyncRule": "Redigera synkregel", "removeSyncRule": "Ta bort synkregel", "removeSyncRuleConfirm": "Sluta synkronisera \"${title}\"? Nedladdade avsnitt behålls.", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "Synkregel skapad — behåller ${count} osedda avsnitt", "syncRuleUpdated": "Synkregel uppdaterad", "syncRuleRemoved": "Synkregel borttagen", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "Synkroniserade ${count} nya avsnitt för ${title}", "activeSyncRules": "Synkregler", "noSyncRules": "Inga synkregler", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 1aa81d62..cddc185c 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1188,9 +1188,15 @@ "editSyncRule": "編輯同步規則", "removeSyncRule": "刪除同步規則", "removeSyncRuleConfirm": "停止同步「${title}」?已下載的單集將會保留。", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "同步規則已建立 — 將保留 ${count} 個未觀看單集", "syncRuleUpdated": "同步規則已更新", "syncRuleRemoved": "同步規則已刪除", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "已為 ${title} 同步 ${count} 個新單集", "activeSyncRules": "同步規則", "noSyncRules": "沒有同步規則", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 309eae76..7c6b819d 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1188,9 +1188,15 @@ "editSyncRule": "编辑同步规则", "removeSyncRule": "删除同步规则", "removeSyncRuleConfirm": "停止同步“${title}”?已下载的剧集将被保留。", + "removeListSyncRuleConfirm": "", + "deleteSyncRuleDownloads": "", + "deleteSyncRuleDownloadsDescription": "", "syncRuleCreated": "同步规则已创建 — 保留 ${count} 集未观看内容", "syncRuleUpdated": "同步规则已更新", "syncRuleRemoved": "同步规则已删除", + "syncRuleAndDownloadsRemoved": "", + "syncRuleCleanupBusy": "", + "syncRuleCleanupUnavailable": "", "syncedNewEpisodes": "已为 ${title} 同步 ${count} 个新剧集", "activeSyncRules": "同步规则", "noSyncRules": "没有同步规则", diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 284be360..05141de9 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -29,6 +29,7 @@ import '../utils/deletion_notifier.dart'; import '../utils/downloaded_version_match.dart'; import '../media/episode_collection.dart'; import '../utils/global_key_utils.dart'; +import '../utils/content_utils.dart'; import '../utils/notification_permission.dart'; import '../utils/watch_state_notifier.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; @@ -96,6 +97,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Persistent sync rules keyed by profile-scoped globalKey // (profileId|serverId:ratingKey). Downloads remain public/shared. final Map _syncRules = {}; + final Set _removingSyncRuleKeys = {}; + bool _syncRuleCleanupInProgress = false; String? _activeProfileId; int _profileGeneration = 0; @@ -1150,12 +1153,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Movies, episodes, and tracks are queued directly. Shows and seasons are /// expanded into their episodes and albums/artists into their tracks (when /// [expandShows] is true). Nested collections/playlists and unknown types - /// are skipped. Future queueListDownload( List items, MediaServerClient client, { DownloadFilter filter = DownloadFilter.all, bool expandShows = true, + SyncRuleItem? syncRule, }) async { if (!_downloadManager.downloadsSupported) return 0; @@ -1165,44 +1168,61 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } if (!_isQueueOwnershipCurrent(ownership)) return 0; - final unwatchedOnly = filter == DownloadFilter.unwatched; - final relatedContext = _RelatedMetadataDownloadContext(); - int count = 0; + final membership = []; + final candidates = []; + if (expandShows) { + if (syncRule != null) { + await _syncRuleExecutor.collectItemsForList(client, items, unwatchedOnly: false, out: membership); + } + if (filter == DownloadFilter.all && syncRule != null) { + candidates.addAll(membership); + } else { + await _syncRuleExecutor.collectItemsForList( + client, + items, + unwatchedOnly: filter == DownloadFilter.unwatched, + out: candidates, + ); + } + } else { + final playableItems = items.where((item) => item.isMovie || item.isEpisode || item.kind == MediaKind.track); + if (syncRule != null) membership.addAll(playableItems); + candidates.addAll( + filter == DownloadFilter.unwatched + ? playableItems.where((item) => item.isUnwatchedOrInProgress) + : playableItems, + ); + } + if (!_isQueueOwnershipCurrent(ownership)) return 0; - Future queueItem(MediaItem item) async { - if (unwatchedOnly && !item.isUnwatchedOrInProgress) return; - final queued = await _queueSingleDownload(item, client, ownership: ownership, relatedContext: relatedContext); - if (queued) count++; + if (syncRule != null) { + for (final item in membership) { + final withServer = _ensureServerId(item, client.serverId); + if (_hasActiveOwnedDownload(withServer.globalKey)) { + await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership); + } + } } - for (final item in items) { + final relatedContext = _RelatedMetadataDownloadContext(); + var count = 0; + for (final item in candidates) { if (!_isQueueOwnershipCurrent(ownership)) return count; - if (item.isMovie || item.isEpisode || item.kind == MediaKind.track) { - await queueItem(item); - } else if (item.isShow || item.isSeason) { - if (!expandShows) continue; - // One-shot recursive expansion for both shows and seasons. - final episodes = []; - await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item); - if (!_isQueueOwnershipCurrent(ownership)) return count; - for (final ep in episodes) { - await queueItem(ep); - if (!_isQueueOwnershipCurrent(ownership)) return count; - } - } else if (item.kind == MediaKind.album || item.kind == MediaKind.artist) { - if (!expandShows) continue; - // Same one-shot expansion for music containers (album/artist → - // tracks) via the shared recursive-leaves call. - final tracks = await client.fetchPlayableDescendants(item.id); - if (!_isQueueOwnershipCurrent(ownership)) return count; - for (final track in tracks) { - await queueItem(_ensureServerId(track, item.serverId)); - if (!_isQueueOwnershipCurrent(ownership)) return count; - } - } else { - // Skip clips, nested collections/playlists, unknown types. - continue; + final withServer = _ensureServerId(item, client.serverId); + if (_hasActiveOwnedDownload(withServer.globalKey)) continue; + final queued = await _queueSingleDownload( + withServer, + client, + ownership: ownership, + relatedContext: relatedContext, + ); + if (syncRule != null) { + await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership); } + if (queued) count++; + } + if (syncRule != null && _isQueueOwnershipCurrent(ownership)) { + await markSyncRuleDownloadLinksInitialized(syncRule.globalKey); } return count; } @@ -1793,6 +1813,56 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Get a sync rule for the given item SyncRuleItem? getSyncRule(String globalKey) => _syncRules[globalKey]; + bool _hasActiveOwnedDownload(String globalKey) { + if (!_ownsDownloadKey(globalKey)) return false; + final progress = _downloads[globalKey]; + return progress != null && + (progress.status == DownloadStatus.downloading || + progress.status == DownloadStatus.completed || + progress.status == DownloadStatus.queued || + progress.status == DownloadStatus.paused); + } + + Future _associateSyncRuleDownload( + SyncRuleItem rule, + String downloadGlobalKey, + _QueueOwnership ownership, + ) async { + if (!_isQueueOwnershipCurrent(ownership) || + _removingSyncRuleKeys.contains(rule.globalKey) || + !_hasActiveOwnedDownload(downloadGlobalKey)) { + return; + } + final currentRule = _syncRules[rule.globalKey]; + if (currentRule == null) return; + await _database.associateSyncRuleDownload(currentRule, downloadGlobalKey); + } + + Future _queueSyncRuleDownload( + MediaItem item, + MediaServerClient client, { + required _QueueOwnership ownership, + required _RelatedMetadataDownloadContext relatedContext, + int mediaIndex = 0, + }) async { + if (!_isQueueOwnershipCurrent(ownership)) return false; + return _queueSingleDownload( + item, + client, + ownership: ownership, + mediaIndex: mediaIndex, + relatedContext: relatedContext, + ); + } + + Future markSyncRuleDownloadLinksInitialized(String globalKey) async { + await _database.markSyncRuleDownloadLinksInitialized(globalKey); + final existing = _syncRules[globalKey]; + if (existing != null) { + _syncRules[globalKey] = existing.copyWith(downloadLinksInitialized: true); + } + } + /// Create (or upsert) a sync rule for a show, season, collection, or playlist. /// /// [targetMetadata], when provided, is stored in the in-memory metadata map so @@ -1881,6 +1951,129 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future deleteSyncRule(String globalKey) async { _requireActiveProfileId(); final existing = _syncRules[globalKey] ?? await _database.getSyncRule(globalKey); + await _deleteSyncRuleRecord(globalKey, existing); + safeNotifyListeners(); + } + + /// Delete a list sync rule and every active-profile download associated only + /// with that rule. Other rules and profile owners keep their copies. + Future deleteSyncRuleAndDownloads(String globalKey, MultiServerManager serverManager) async { + final profileId = _requireActiveProfileId(); + if (_syncRuleCleanupInProgress || _syncRuleExecutor.isExecuting) { + throw const SyncRuleCleanupBusyException(); + } + + final ownership = _captureQueueOwnership(); + final existing = await _database.getSyncRule(globalKey); + if (existing == null || existing.profileId != profileId) return; + if (existing.targetType != ContentTypes.collection && existing.targetType != ContentTypes.playlist) { + throw ArgumentError.value(existing.targetType, 'targetType', 'Only collection/playlist rules support cleanup'); + } + + var stateChanged = false; + _syncRuleCleanupInProgress = true; + try { + await _backfillUninitializedRuleLinksForServer(existing, serverManager, ownership); + if (!_isQueueOwnershipCurrent(ownership)) { + throw const SyncRuleCleanupBusyException(); + } + + final trackedRule = await _database.getSyncRule(globalKey); + if (trackedRule == null || !trackedRule.downloadLinksInitialized) { + throw SyncRuleCleanupUnavailableException(globalKey); + } + + _removingSyncRuleKeys.add(globalKey); + await _database.updateSyncRuleEnabled(globalKey, false); + final cachedRule = _syncRules[globalKey]; + if (cachedRule != null) { + _syncRules[globalKey] = cachedRule.copyWith(enabled: false, downloadLinksInitialized: true); + } + stateChanged = true; + + final downloadKeys = await _database.getExclusiveSyncRuleDownloadKeys(trackedRule); + _batchDeletionDepth++; + try { + for (final downloadKey in downloadKeys) { + if (!_isQueueOwnershipCurrent(ownership)) { + throw const SyncRuleCleanupBusyException(); + } + final wasOwned = _ownsDownloadKey(downloadKey); + final metadata = _metadata[downloadKey]; + await _deleteDownload(downloadKey, notify: false); + if (wasOwned && metadata != null) { + DeletionNotifier().notifyDeletedItem(item: metadata, isDownloadOnly: true); + } + } + } finally { + _batchDeletionDepth--; + } + + await _deleteSyncRuleRecord(globalKey, trackedRule); + appLogger.i('Deleted sync rule and ${downloadKeys.length} associated downloads: $globalKey'); + } finally { + _removingSyncRuleKeys.remove(globalKey); + _syncRuleCleanupInProgress = false; + if (stateChanged) safeNotifyListeners(); + } + } + + Future _backfillUninitializedRuleLinksForServer( + SyncRuleItem target, + MultiServerManager serverManager, + _QueueOwnership ownership, + ) async { + final rules = await _database.getUninitializedSyncRulesForServer( + profileId: target.profileId, + serverId: ServerId(target.serverId), + ); + final requiredRules = rules.where((rule) => rule.enabled || rule.globalKey == target.globalKey); + for (final rule in requiredRules) { + if (!_isQueueOwnershipCurrent(ownership)) { + throw const SyncRuleCleanupBusyException(); + } + switch (rule.targetType) { + case ContentTypes.show: + case ContentTypes.season: + final downloadKeys = await _database.getOwnedDownloadKeysForAncestorRule( + profileId: rule.profileId, + serverId: ServerId(rule.serverId), + ratingKey: rule.ratingKey, + matchGrandparent: rule.targetType == ContentTypes.show, + ); + for (final downloadKey in downloadKeys) { + await _database.associateSyncRuleDownload(rule, downloadKey); + } + await _database.markSyncRuleDownloadLinksInitialized(rule.globalKey); + break; + case ContentTypes.collection: + case ContentTypes.playlist: + final backfilled = await _syncRuleExecutor.backfillListRuleDownloadLinks( + rule: rule, + serverManager: serverManager, + downloads: downloads, + metadata: Map.unmodifiable(_metadata), + associateDownload: (resolvedRule, downloadKey) async { + if (_isQueueOwnershipCurrent(ownership) && _hasActiveOwnedDownload(downloadKey)) { + await _database.associateSyncRuleDownload(resolvedRule, downloadKey); + } + }, + ); + if (!backfilled) { + throw SyncRuleCleanupUnavailableException(rule.globalKey); + } + break; + default: + throw SyncRuleCleanupUnavailableException(rule.globalKey); + } + final cachedRule = _syncRules[rule.globalKey]; + if (cachedRule != null) { + _syncRules[rule.globalKey] = cachedRule.copyWith(downloadLinksInitialized: true); + } + } + } + + Future _deleteSyncRuleRecord(String globalKey, SyncRuleItem? existing) async { final publicGlobalKey = existing == null ? globalKey : buildGlobalKey(ServerId(existing.serverId), existing.ratingKey); @@ -1891,7 +2084,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (!_downloads.containsKey(publicGlobalKey)) { _metadata.remove(publicGlobalKey); } - safeNotifyListeners(); appLogger.i('Deleted sync rule: $globalKey'); } @@ -1904,6 +2096,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Returns titles of newly queued items (for snackbar display). Future> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async { if (!_downloadManager.downloadsSupported) return []; + if (_syncRuleCleanupInProgress) return []; final profileId = _activeProfileId; if (profileId == null || profileId.isEmpty) return []; @@ -1916,17 +2109,17 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin serverManager: serverManager, downloads: downloads, metadata: Map.unmodifiable(_metadata), - queueSingleDownload: (episode, client, {int mediaIndex = 0}) async { - // A profile switch mid-pass must not keep queueing the old - // profile's rules; whatever does get queued is claimed for the - // rule's owner, never the new active profile. - if (!_isQueueOwnershipCurrent(ownership)) return false; - return _queueSingleDownload( + associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership), + queueSingleDownload: (episode, client, {int mediaIndex = 0}) { + // A profile switch mid-pass must not keep queueing the old profile's + // rules; whatever does get queued is claimed for the rule's owner, + // never the new active profile. + return _queueSyncRuleDownload( episode, client, ownership: ownership, - mediaIndex: mediaIndex, relatedContext: relatedContext, + mediaIndex: mediaIndex, ); }, isOffline: _offlineSource?.isOffline ?? false, @@ -1943,6 +2136,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// `addToCollection`). Bypasses the cooldown. Future executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async { if (!_downloadManager.downloadsSupported) return null; + if (_syncRuleCleanupInProgress) return null; final profileId = _activeProfileId; if (profileId == null || profileId.isEmpty) return null; @@ -1956,16 +2150,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin serverManager: serverManager, downloads: downloads, metadata: Map.unmodifiable(_metadata), - queueSingleDownload: (episode, client, {int mediaIndex = 0}) async { - if (!_isQueueOwnershipCurrent(ownership)) return false; - return _queueSingleDownload( - episode, - client, - ownership: ownership, - mediaIndex: mediaIndex, - relatedContext: relatedContext, - ); - }, + associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership), + queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSyncRuleDownload( + episode, + client, + ownership: ownership, + relatedContext: relatedContext, + mediaIndex: mediaIndex, + ), isOffline: _offlineSource?.isOffline ?? false, ); } @@ -2012,6 +2204,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } +class SyncRuleCleanupBusyException implements Exception { + const SyncRuleCleanupBusyException(); +} + +class SyncRuleCleanupUnavailableException implements Exception { + final String ruleGlobalKey; + + const SyncRuleCleanupUnavailableException(this.ruleGlobalKey); +} + /// Exception thrown when download is blocked due to cellular-only setting class CellularDownloadBlockedException implements Exception { String get message => t.settings.cellularDownloadBlocked; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 46e8456c..a16f643b 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1304,8 +1304,8 @@ class _MediaDetailScreenState extends State globalKey: ruleKey, displayTitle: metadata.displayTitle, ); - if (removed && context.mounted) { - showSuccessSnackBar(context, t.downloads.syncRuleRemoved); + if (removed != null && context.mounted) { + showSuccessSnackBar(context, syncRuleRemovalMessage(removed)); } case _SyncRuleAction.delete: diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 1551297b..0815f316 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -1,5 +1,4 @@ import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter/foundation.dart'; import '../media/ids.dart'; import '../database/app_database.dart'; @@ -22,6 +21,11 @@ class SyncRuleFilter { static const String unwatched = 'unwatched'; } +typedef AssociateSyncRuleDownload = Future Function(SyncRuleItem rule, String downloadGlobalKey); +typedef QueueSyncRuleDownload = Future Function(MediaItem item, MediaServerClient client, {int mediaIndex}); + +typedef _ResolvedListRuleItems = ({List membership, List candidates}); + /// Result of executing a single sync rule. class SyncRuleResult { final String globalKey; @@ -61,14 +65,16 @@ class SyncRuleExecutor { /// to bypass it: we already know state changed and the UX expectation is /// immediate feedback. /// - /// [queueSingleDownload] queues a single movie/episode and returns `true` if it - /// was actually queued (false when the item was already present). + /// [associateDownload] records coverage for an already-present download. + /// [queueSingleDownload] queues and records a missing download, returning + /// whether a queue entry was created. Future> executeSyncRules({ required String profileId, required MultiServerManager serverManager, required Map downloads, required Map metadata, - required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required AssociateSyncRuleDownload associateDownload, + required QueueSyncRuleDownload queueSingleDownload, required bool isOffline, bool force = false, }) async { @@ -120,6 +126,7 @@ class SyncRuleExecutor { downloads: downloads, metadata: metadata, queueSingleDownload: queueSingleDownload, + associateDownload: associateDownload, ); if (result != null && result.queuedCount > 0) { results.add(result); @@ -144,7 +151,8 @@ class SyncRuleExecutor { required MultiServerManager serverManager, required Map downloads, required Map metadata, - required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required AssociateSyncRuleDownload associateDownload, + required QueueSyncRuleDownload queueSingleDownload, required bool isOffline, }) async { if (_isExecuting) { @@ -175,6 +183,7 @@ class SyncRuleExecutor { downloads: downloads, metadata: metadata, queueSingleDownload: queueSingleDownload, + associateDownload: associateDownload, ); } catch (e) { appLogger.w('Failed to execute single sync rule $globalKey: $e'); @@ -184,12 +193,56 @@ class SyncRuleExecutor { } } + /// Populate persistent coverage for a legacy list rule without queueing + /// missing items. Returns false when the rule cannot be resolved safely. + Future backfillListRuleDownloadLinks({ + required SyncRuleItem rule, + required MultiServerManager serverManager, + required Map downloads, + required Map metadata, + required AssociateSyncRuleDownload associateDownload, + }) async { + if (_isExecuting || (rule.targetType != ContentTypes.collection && rule.targetType != ContentTypes.playlist)) { + return false; + } + + final client = serverManager.getClient(ServerId(rule.serverId)); + if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) { + return false; + } + + _isExecuting = true; + try { + final resolved = await _resolveListRuleItems( + rule: rule, + client: client, + clientScopeId: _clientScopeIdFor(client, ServerId(rule.serverId)), + profileId: rule.profileId, + metadata: metadata, + ); + for (final item in resolved.membership) { + final globalKey = buildGlobalKey(ServerId(rule.serverId), item.id); + if (_isActiveDownload(downloads[globalKey])) { + await associateDownload(rule, globalKey); + } + } + await _completeRuleExecution(rule.globalKey); + return true; + } catch (error, stackTrace) { + appLogger.w('Failed to backfill sync rule ${rule.globalKey}', error: error, stackTrace: stackTrace); + return false; + } finally { + _isExecuting = false; + } + } + Future _executeRule({ required SyncRuleItem rule, required MultiServerManager serverManager, required Map downloads, required Map metadata, - required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required AssociateSyncRuleDownload associateDownload, + required QueueSyncRuleDownload queueSingleDownload, }) async { final client = serverManager.getClient(ServerId(rule.serverId)); if (client == null || !serverManager.isServerOnline(ServerId(rule.serverId))) { @@ -222,6 +275,7 @@ class SyncRuleExecutor { profileId: rule.profileId, downloads: downloads, metadata: resolvedMetadata, + associateDownload: associateDownload, queueSingleDownload: queueSingleDownload, ); case ContentTypes.collection: @@ -233,6 +287,7 @@ class SyncRuleExecutor { profileId: rule.profileId, downloads: downloads, metadata: resolvedMetadata, + associateDownload: associateDownload, queueSingleDownload: queueSingleDownload, ); default: @@ -246,6 +301,10 @@ class SyncRuleExecutor { return cacheServerId == serverId || cacheServerId.isEmpty ? null : cacheServerId; } + Future _completeRuleExecution(String globalKey) { + return _database.completeSyncRuleExecution(globalKey); + } + /// Keep [rule.episodeCount] unwatched episodes queued for a show/season /// (0 = all). Always "unwatched" — watched/all filtering doesn't apply here. Future _executeEpisodeRule({ @@ -255,7 +314,8 @@ class SyncRuleExecutor { required String profileId, required Map downloads, required Map metadata, - required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required AssociateSyncRuleDownload associateDownload, + required QueueSyncRuleDownload queueSingleDownload, }) async { final fromServer = []; final sourceMetadata = metadata[rule.globalKey]; @@ -277,14 +337,17 @@ class SyncRuleExecutor { if (unwatchedEpisodes.isEmpty) { appLogger.d('Sync rule ${rule.globalKey}: no unwatched episodes available'); - await _database.updateSyncRuleLastExecuted(rule.globalKey); + await _completeRuleExecution(rule.globalKey); return null; } int alreadyHave = 0; for (final ep in unwatchedEpisodes) { final gk = buildGlobalKey(ServerId(rule.serverId), ep.id); - if (_isActiveDownload(downloads[gk])) alreadyHave++; + if (_isActiveDownload(downloads[gk])) { + alreadyHave++; + await associateDownload(rule, gk); + } } // episodeCount == 0 means "all unwatched" — target is total unwatched count @@ -292,7 +355,7 @@ class SyncRuleExecutor { final deficit = targetCount - alreadyHave; if (deficit <= 0) { appLogger.d('Sync rule ${rule.globalKey}: no deficit ($alreadyHave/$targetCount already have)'); - await _database.updateSyncRuleLastExecuted(rule.globalKey); + await _completeRuleExecution(rule.globalKey); return null; } @@ -305,13 +368,14 @@ class SyncRuleExecutor { final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId); final ok = await queueSingleDownload(episodeWithServer, client, mediaIndex: rule.mediaIndex); + await associateDownload(rule, gk); if (ok) { queued++; appLogger.i('Sync rule ${rule.globalKey}: queued ${ep.title ?? ep.id}'); } } - await _database.updateSyncRuleLastExecuted(rule.globalKey); + await _completeRuleExecution(rule.globalKey); final displayTitle = metadata[rule.globalKey]?.title; appLogger.i('Sync rule ${rule.globalKey}: queued $queued episodes (had $alreadyHave/$targetCount)'); @@ -329,47 +393,31 @@ class SyncRuleExecutor { required String profileId, required Map downloads, required Map metadata, - required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, + required AssociateSyncRuleDownload associateDownload, + required QueueSyncRuleDownload queueSingleDownload, }) async { - final List rootItems; + final _ResolvedListRuleItems resolved; try { - // Page list calls so long collections/playlists don't truncate at the - // default limit. Plex collections use a distinct collections endpoint; - // Jellyfin's collection page implementation maps to its children API. - if (rule.targetType == ContentTypes.collection) { - rootItems = await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey]); - } else { - rootItems = await _fetchAllPlaylistItems(client, rule.ratingKey); - } + resolved = await _resolveListRuleItems( + rule: rule, + client: client, + clientScopeId: clientScopeId, + profileId: profileId, + metadata: metadata, + ); } catch (e) { appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e'); return null; } - if (rootItems.isEmpty) { - appLogger.d('Sync rule ${rule.globalKey}: list is empty'); - await _database.updateSyncRuleLastExecuted(rule.globalKey); - return null; + for (final item in resolved.membership) { + final globalKey = buildGlobalKey(ServerId(rule.serverId), item.id); + if (_isActiveDownload(downloads[globalKey])) { + await associateDownload(rule, globalKey); + } } - final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched; - final collected = []; - await collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected); - - final candidates = unwatchedOnly - ? await _excludeLocallyWatched( - episodes: collected, - serverId: ServerId(rule.serverId), - profileId: profileId, - clientScopeId: clientScopeId, - ) - : collected; - - if (candidates.isEmpty) { - appLogger.d('Sync rule ${rule.globalKey}: no candidates after filtering'); - await _database.updateSyncRuleLastExecuted(rule.globalKey); - return null; - } + final candidates = resolved.candidates; int queued = 0; for (final item in candidates) { @@ -378,13 +426,14 @@ class SyncRuleExecutor { final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId); final ok = await queueSingleDownload(itemWithServer, client, mediaIndex: 0); + await associateDownload(rule, gk); if (ok) { queued++; appLogger.i('Sync rule ${rule.globalKey}: queued ${item.title ?? item.id}'); } } - await _database.updateSyncRuleLastExecuted(rule.globalKey); + await _completeRuleExecution(rule.globalKey); final displayTitle = metadata[rule.globalKey]?.title; appLogger.i('Sync rule ${rule.globalKey}: queued $queued items from ${candidates.length} candidates'); @@ -392,6 +441,42 @@ class SyncRuleExecutor { return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued); } + Future<_ResolvedListRuleItems> _resolveListRuleItems({ + required SyncRuleItem rule, + required MediaServerClient client, + required String? clientScopeId, + required String profileId, + required Map metadata, + }) async { + // Page list calls so long collections/playlists don't truncate at the + // default limit. Plex collections use a distinct collections endpoint; + // Jellyfin's collection page implementation maps to its children API. + final rootItems = rule.targetType == ContentTypes.collection + ? await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey]) + : await _fetchAllPlaylistItems(client, rule.ratingKey); + if (rootItems.isEmpty) { + return (membership: const [], candidates: const []); + } + + // Resolve the complete membership for cleanup provenance. The rule's + // unwatched filter applies only to queueing; watched downloads still + // belong to the list and must be removable with it. + final membership = []; + await collectItemsForList(client, rootItems, unwatchedOnly: false, out: membership); + if (rule.downloadFilter != SyncRuleFilter.unwatched) { + return (membership: membership, candidates: membership); + } + final serverUnwatched = []; + await collectItemsForList(client, rootItems, unwatchedOnly: true, out: serverUnwatched); + final candidates = await _excludeLocallyWatched( + episodes: serverUnwatched, + serverId: ServerId(rule.serverId), + profileId: profileId, + clientScopeId: clientScopeId, + ); + return (membership: membership, candidates: candidates); + } + /// Page through every item in a playlist using the shared playlist page size. Future> _fetchAllPlaylistItems(MediaServerClient client, String playlistId) async { return fetchAllPlaylistItems(client, playlistId); @@ -417,7 +502,6 @@ class SyncRuleExecutor { /// sync rules). Clips, nested collections/playlists, and unknown types are /// skipped. [unwatchedOnly] applies the same played-state filter to every /// kind — for tracks that means Plex/Jellyfin play counts. - @visibleForTesting Future collectItemsForList( MediaServerClient client, List items, { diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 1647da89..1974b75d 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -2,15 +2,20 @@ import 'package:flutter/material.dart'; import '../media/ids.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../database/app_database.dart'; import '../providers/download_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../services/settings_service.dart'; import '../services/sync_rule_executor.dart'; import '../widgets/background_download_warning_banner.dart'; +import '../widgets/dialog_action_button.dart'; +import '../widgets/focusable_list_tile.dart'; +import 'app_logger.dart'; import 'content_utils.dart'; import 'dialogs.dart'; import 'download_version_utils.dart'; @@ -31,6 +36,12 @@ enum _DownloadChoice { all, unwatched, next5, next10, custom, delete } /// Whether the user chose a one-time download or a persistent sync rule. enum _SyncChoice { downloadOnce, keepSynced } +enum SyncRuleRemovalResult { ruleOnly, ruleAndDownloads } + +String syncRuleRemovalMessage(SyncRuleRemovalResult result) => result == SyncRuleRemovalResult.ruleAndDownloads + ? t.downloads.syncRuleAndDownloadsRemoved + : t.downloads.syncRuleRemoved; + /// Result of the download dialog + queue operation. class DownloadResult { final int count; @@ -251,6 +262,7 @@ Future showListDownloadOptionsAndQueue( bool syncRuleCreated = false; bool syncRuleUpdated = false; + SyncRuleItem? syncRule; if (syncChoice == _SyncChoice.keepSynced) { final ruleKey = downloadProvider.syncRuleKeyFor(ServerId(serverId), rootMetadata.id); @@ -269,9 +281,10 @@ Future showListDownloadOptionsAndQueue( ); syncRuleCreated = true; } + syncRule = downloadProvider.getSyncRule(ruleKey); } - final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter); + final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter, syncRule: syncRule); return DownloadResult( count: count, @@ -356,8 +369,8 @@ Future editSyncRuleCount( globalKey: globalKey, displayTitle: displayTitle ?? globalKey, ); - if (removed && context.mounted) { - showSuccessSnackBar(context, t.downloads.syncRuleRemoved); + if (removed != null && context.mounted) { + showSuccessSnackBar(context, syncRuleRemovalMessage(removed)); } return false; } @@ -388,23 +401,101 @@ Future editSyncRuleFilter( return true; } -/// Shows a confirmation dialog to remove a sync rule. Returns true if removed. -Future confirmAndRemoveSyncRule( +/// Shows a confirmation dialog to remove a sync rule. +Future confirmAndRemoveSyncRule( BuildContext context, { required DownloadProvider downloadProvider, required String globalKey, required String displayTitle, }) async { - final confirmed = await showConfirmDialog( - context, - title: t.downloads.removeSyncRule, - message: t.downloads.removeSyncRuleConfirm(title: displayTitle), - confirmText: t.downloads.removeSyncRule, - ); - if (!confirmed || !context.mounted) return false; + final rule = downloadProvider.getSyncRule(globalKey); + if (rule == null) return null; - await downloadProvider.deleteSyncRule(globalKey); - return true; + final bool deleteDownloads; + if (rule.isListRule) { + final choice = await _showListSyncRuleRemovalDialog(context, displayTitle); + if (choice == null || !context.mounted) return null; + deleteDownloads = choice; + } else { + final confirmed = await showConfirmDialog( + context, + title: t.downloads.removeSyncRule, + message: t.downloads.removeSyncRuleConfirm(title: displayTitle), + confirmText: t.downloads.removeSyncRule, + ); + if (!confirmed || !context.mounted) return null; + deleteDownloads = false; + } + + try { + if (deleteDownloads) { + final serverManager = context.read().serverManager; + await downloadProvider.deleteSyncRuleAndDownloads(globalKey, serverManager); + return SyncRuleRemovalResult.ruleAndDownloads; + } + await downloadProvider.deleteSyncRule(globalKey); + return SyncRuleRemovalResult.ruleOnly; + } on SyncRuleCleanupBusyException { + if (context.mounted) showErrorSnackBar(context, t.downloads.syncRuleCleanupBusy); + return null; + } on SyncRuleCleanupUnavailableException { + if (context.mounted) showErrorSnackBar(context, t.downloads.syncRuleCleanupUnavailable); + return null; + } catch (error, stackTrace) { + appLogger.e('Failed to remove sync rule', error: error, stackTrace: stackTrace); + if (context.mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: error.toString())); + } + return null; + } +} + +Future _showListSyncRuleRemovalDialog(BuildContext context, String displayTitle) { + var deleteDownloads = false; + return showScopedDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + final colorScheme = Theme.of(context).colorScheme; + return AlertDialog( + title: Text(t.downloads.removeSyncRule), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(t.downloads.removeListSyncRuleConfirm(title: displayTitle)), + const SizedBox(height: 12), + FocusableSwitchListTile( + key: const ValueKey('delete_sync_rule_downloads'), + value: deleteDownloads, + onChanged: (value) => setState(() => deleteDownloads = value), + title: Text(t.downloads.deleteSyncRuleDownloads), + subtitle: Text(t.downloads.deleteSyncRuleDownloadsDescription), + contentPadding: EdgeInsets.zero, + ), + ], + ), + actions: [ + DialogActionButton( + autofocus: true, + onPressed: () => Navigator.pop(dialogContext), + label: t.common.cancel, + ), + DialogActionButton( + onPressed: () => Navigator.pop(dialogContext, deleteDownloads), + label: t.downloads.removeSyncRule, + isPrimary: true, + style: deleteDownloads + ? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError) + : null, + ), + ], + ); + }, + ); + }, + ); } /// Whether this rule targets a collection or playlist (as opposed to a @@ -462,7 +553,7 @@ Future removeSyncRuleAndSnack( globalKey: globalKey, displayTitle: displayTitle, ); - if (removed && context.mounted) { - showSuccessSnackBar(context, t.downloads.syncRuleRemoved); + if (removed != null && context.mounted) { + showSuccessSnackBar(context, syncRuleRemovalMessage(removed)); } } diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 887eddf1..b81c1fdf 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -781,6 +781,55 @@ class _AppDatabaseTestSuite { db = AppDatabase.forTesting(NativeDatabase.memory()); } }); + test('v20 migration creates sync download associations without claiming legacy rules', () async { + await db.close(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_v20_migration_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + AppDatabase? seeded; + AppDatabase? reopened; + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await seeded.select(seeded.syncRuleDownloads).get(); + await seeded.insertSyncRule( + profileId: 'profile-a', + serverId: ServerId('server'), + ratingKey: 'playlist', + globalKey: 'profile-a|server:playlist', + targetType: 'playlist', + episodeCount: 0, + ); + await seeded.customStatement('DROP TABLE sync_rule_downloads'); + await seeded.customStatement('ALTER TABLE sync_rules DROP COLUMN download_links_initialized'); + await seeded.customStatement('PRAGMA user_version = 19'); + await seeded.close(); + seeded = null; + + reopened = AppDatabase.forTesting(NativeDatabase(file)); + final legacyRule = await reopened.getSyncRule('profile-a|server:playlist'); + expect(legacyRule, isNotNull); + expect(legacyRule!.downloadLinksInitialized, isFalse); + expect(await reopened.select(reopened.syncRuleDownloads).get(), isEmpty); + + await reopened.insertDownload( + serverId: ServerId('server'), + ratingKey: 'episode', + globalKey: 'server:episode', + type: 'episode', + status: DownloadStatus.completed.index, + ); + await reopened.associateSyncRuleDownload(legacyRule, 'server:episode'); + expect(await reopened.getSyncRuleDownloadLinks(legacyRule.id), hasLength(1)); + + await reopened.deleteSyncRule(legacyRule.globalKey); + expect(await reopened.getSyncRuleDownloadLinks(legacyRule.id), isEmpty); + } finally { + await reopened?.close(); + await seeded?.close(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }); }); _registerLegacyDesktopMigrationTests(); @@ -2133,6 +2182,55 @@ class _AppDatabaseTestSuite { expect(remaining, hasLength(1)); expect(remaining.first.globalKey, 'srv:11'); }); + test('exclusive sync download keys preserve downloads covered by another rule', () async { + Future insertRule(String profileId, String id) async { + final globalKey = '$profileId|srv:$id'; + await db.insertSyncRule( + profileId: profileId, + serverId: ServerId('srv'), + ratingKey: id, + globalKey: globalKey, + targetType: 'playlist', + episodeCount: 0, + ); + return (await db.getSyncRule(globalKey))!; + } + + Future insertOwnedDownload(String id, List profileIds) async { + final globalKey = 'srv:$id'; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: id, + globalKey: globalKey, + type: 'episode', + status: DownloadStatus.completed.index, + ); + for (final profileId in profileIds) { + await db.addDownloadOwner(profileId: profileId, globalKey: globalKey); + } + } + + final target = await insertRule('profile-a', 'playlist-a'); + final sibling = await insertRule('profile-a', 'playlist-b'); + final otherProfile = await insertRule('profile-b', 'playlist-c'); + await insertOwnedDownload('exclusive', ['profile-a']); + await insertOwnedDownload('sibling-shared', ['profile-a']); + await insertOwnedDownload('profile-shared', ['profile-a', 'profile-b']); + + await db.associateSyncRuleDownload(target, 'srv:exclusive'); + await db.associateSyncRuleDownload(target, 'srv:sibling-shared'); + await db.associateSyncRuleDownload(target, 'srv:profile-shared'); + await db.associateSyncRuleDownload(sibling, 'srv:sibling-shared'); + await db.associateSyncRuleDownload(otherProfile, 'srv:profile-shared'); + + expect( + await db.getExclusiveSyncRuleDownloadKeys(target), + unorderedEquals(['srv:exclusive', 'srv:profile-shared']), + ); + + await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv:profile-shared'); + expect(await db.getSyncRuleDownloadLinks(otherProfile.id), hasLength(1)); + }); }); } } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index ba376e4a..1ba021ec 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -16,6 +16,7 @@ import 'package:plezy/models/download_models.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/api_cache.dart'; +import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -587,6 +588,30 @@ void main() { p.dispose(); }); + test('deleteSyncRule keeps downloads previously associated with the rule', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + addTearDown(p.dispose); + await p.ensureInitialized(); + await p.createSyncRule(serverId: ServerId('srv'), ratingKey: 'playlist', targetType: 'playlist', episodeCount: 0); + final ruleKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist'); + final rule = (await db.getSyncRule(ruleKey))!; + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'episode', + globalKey: 'srv:episode', + type: 'episode', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:episode'); + await db.associateSyncRuleDownload(rule, 'srv:episode'); + + await p.deleteSyncRule(ruleKey); + + expect(await db.getSyncRule(ruleKey), isNull); + expect(await db.getDownloadedMedia('srv:episode'), isNotNull); + expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:episode'), isNotNull); + }); + test('deleteSyncRule releases targetMetadata when no download holds it', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); @@ -646,6 +671,116 @@ void main() { p.dispose(); }); + test('deleteSyncRuleAndDownloads removes only downloads exclusive to the rule and active profile', () 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-a', + targetType: 'playlist', + episodeCount: 0, + ); + await p.createSyncRule( + serverId: ServerId('srv'), + ratingKey: 'playlist-b', + targetType: 'playlist', + episodeCount: 0, + ); + final targetKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist-a'); + final siblingKey = p.syncRuleKeyFor(ServerId('srv'), 'playlist-b'); + final targetRule = (await db.getSyncRule(targetKey))!; + final siblingRule = (await db.getSyncRule(siblingKey))!; + await db.markSyncRuleDownloadLinksInitialized(targetKey); + await db.markSyncRuleDownloadLinksInitialized(siblingKey); + + final items = { + 'srv:exclusive': testMediaItem( + id: 'exclusive', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Exclusive', + serverId: ServerId('srv'), + ), + 'srv:rule-shared': testMediaItem( + id: 'rule-shared', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Rule shared', + serverId: ServerId('srv'), + ), + 'srv:profile-shared': testMediaItem( + id: 'profile-shared', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Profile shared', + serverId: ServerId('srv'), + ), + }; + for (final entry in items.entries) { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: entry.value.id, + globalKey: entry.key, + type: 'episode', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: entry.key); + await db.associateSyncRuleDownload(targetRule, entry.key); + } + await db.associateSyncRuleDownload(siblingRule, 'srv:rule-shared'); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared'); + + p.debugSeedState( + downloads: { + for (final key in items.keys) key: DownloadProgress(globalKey: key, status: DownloadStatus.completed), + }, + metadata: items, + ownedDownloadKeys: items.keys.toSet(), + ); + + await p.deleteSyncRuleAndDownloads(targetKey, serverManager); + + expect(await db.getSyncRule(targetKey), isNull); + expect(await db.getSyncRule(siblingKey), isNotNull); + expect(await db.getDownloadedMedia('srv:exclusive'), isNull); + expect(await db.getDownloadedMedia('srv:rule-shared'), isNotNull); + expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:rule-shared'), isNotNull); + expect(await db.getDownloadedMedia('srv:profile-shared'), isNotNull); + expect(await db.getDownloadOwner(profileId: 'test-profile', globalKey: 'srv:profile-shared'), isNull); + expect(await db.getDownloadOwner(profileId: 'profile-b', globalKey: 'srv:profile-shared'), isNotNull); + }); + + test('deleteSyncRuleAndDownloads keeps an unresolvable legacy list rule intact', () 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('missing-server'), + ratingKey: 'deleted-playlist', + targetType: 'playlist', + episodeCount: 0, + ); + final ruleKey = p.syncRuleKeyFor(ServerId('missing-server'), 'deleted-playlist'); + + await expectLater( + p.deleteSyncRuleAndDownloads(ruleKey, serverManager), + throwsA( + isA().having((error) => error.ruleGlobalKey, 'ruleGlobalKey', ruleKey), + ), + ); + + expect(await db.getSyncRule(ruleKey), isNotNull); + expect((await db.getSyncRule(ruleKey))!.enabled, isTrue); + expect(p.hasSyncRule(ruleKey), isTrue); + }); + test('watch events target active-profile parent sync rules', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index 2660cc8b..0bad48ed 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:drift/native.dart'; import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; @@ -85,6 +86,16 @@ MediaItem _show(ServerId serverId, String ratingKey, String title) { ); } +MediaItem _playlist(ServerId serverId, String ratingKey, String title) { + return testMediaItem( + id: ratingKey, + backend: MediaBackend.plex, + kind: MediaKind.playlist, + title: title, + serverId: serverId, + ); +} + class _FakeConnectionRegistry extends ConnectionRegistry { _FakeConnectionRegistry(super.db, this.connections); @@ -141,6 +152,15 @@ void main() { ); } + Future insertPlaylistRule(ServerId serverId, String ratingKey) { + return downloadProvider.createSyncRule( + serverId: serverId, + ratingKey: ratingKey, + targetType: 'playlist', + episodeCount: 0, + ); + } + Future pumpScreen(WidgetTester tester, {bool keyboardMode = false}) async { downloadProvider.debugSeedState( metadata: { @@ -148,6 +168,7 @@ void main() { 'jf-machine:show-2': _show(ServerId('jf-machine'), 'show-2', 'Jellyfin Show'), 'auth-jf:show-3': _show(ServerId('auth-jf'), 'show-3', 'Auth Show'), 'unknown-srv:show-4': _show(ServerId('unknown-srv'), 'show-4', 'Unknown Show'), + 'playlist-srv:playlist-1': _playlist(ServerId('playlist-srv'), 'playlist-1', 'Road Trip'), }, ); @@ -255,6 +276,58 @@ void main() { expect(find.text('No sync rules'), findsOneWidget); }); + testWidgets('playlist rule removal exposes and runs the destructive cleanup choice', (tester) async { + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + await insertPlaylistRule(ServerId('playlist-srv'), 'playlist-1'); + final ruleKey = downloadProvider.syncRuleKeyFor(ServerId('playlist-srv'), 'playlist-1'); + await db.markSyncRuleDownloadLinksInitialized(ruleKey); + + await pumpScreen(tester); + await tester.drag(find.text('Road Trip'), const Offset(-140, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('sync_rule_swipe_delete'))); + await tester.pumpAndSettle(); + + expect(find.text('Stop syncing "Road Trip"?'), findsOneWidget); + final toggle = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('delete_sync_rule_downloads')), + matching: find.byType(SwitchListTile), + ), + ); + expect(toggle.value, isFalse); + + final removalCompleted = Completer(); + void handleRemoval() { + if (!downloadProvider.hasSyncRule(ruleKey) && !removalCompleted.isCompleted) { + removalCompleted.complete(); + } + } + + downloadProvider.addListener(handleRemoval); + addTearDown(() => downloadProvider.removeListener(handleRemoval)); + + await tester.tap(find.byKey(const ValueKey('delete_sync_rule_downloads'))); + await tester.pump(); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('delete_sync_rule_downloads')), + matching: find.byType(SwitchListTile), + ), + ) + .value, + isTrue, + ); + await tester.tap(find.widgetWithText(FilledButton, 'Remove sync rule')); + await tester.runAsync(() => removalCompleted.future.timeout(const Duration(seconds: 5))); + await tester.pumpAndSettle(); + + expect(await db.getSyncRule(ruleKey), isNull); + expect(find.text('Sync rule and associated downloads removed'), findsOneWidget); + }); + testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async { multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); await insertRule(ServerId('orphan-srv'), '76672'); diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 52f17969..18e44edf 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -106,6 +106,7 @@ void main() { serverManager: manager, downloads: const {}, metadata: const {}, + associateDownload: (_, _) async {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add((item: item, client: client)); return true; @@ -177,6 +178,7 @@ void main() { serverManager: manager, downloads: const {}, metadata: const {}, + associateDownload: (_, _) async {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; @@ -225,6 +227,7 @@ void main() { serverManager: manager, downloads: const {}, metadata: const {}, + associateDownload: (_, _) async {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true, isOffline: false, force: true, @@ -278,6 +281,7 @@ void main() { ); final queued = []; + final associated = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-b', @@ -286,6 +290,7 @@ void main() { 'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), }, metadata: const {}, + associateDownload: (_, globalKey) async => associated.add(globalKey), queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; @@ -297,6 +302,8 @@ void main() { expect(results, isEmpty); expect(queued, isEmpty); expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); + expect(associated, ['jf-machine:ep-1']); + expect((await db.getSyncRule('profile-b|jf-machine:show-1'))!.downloadLinksInitialized, isTrue); }); test('show sync rule respects includeSpecials=false when expanding episodes', () async { @@ -333,6 +340,7 @@ void main() { serverManager: manager, downloads: const {}, metadata: {ruleKey: show}, + associateDownload: (_, _) async {}, queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; @@ -346,6 +354,49 @@ void main() { expect(client.fetchPlayableDescendantsCalls, ['show-1']); }); + test('Jellyfin playlist sync associates an already-downloaded member', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final client = _PlaylistPagingClient(); + manager.debugRegisterClientForTesting(client); + const ruleKey = 'profile-a|jf-machine:playlist-1'; + await db.insertSyncRule( + profileId: 'profile-a', + serverId: ServerId('jf-machine'), + ratingKey: 'playlist-1', + globalKey: ruleKey, + targetType: 'playlist', + episodeCount: 0, + downloadFilter: SyncRuleFilter.all, + ); + final associated = []; + + final results = await SyncRuleExecutor(database: db).executeSyncRules( + profileId: 'profile-a', + serverManager: manager, + downloads: const { + 'jf-machine:episode-1': DownloadProgress(globalKey: 'jf-machine:episode-1', status: DownloadStatus.completed), + }, + metadata: const {}, + associateDownload: (_, globalKey) async => associated.add(globalKey), + queueSingleDownload: (_, _, {int mediaIndex = 0}) async { + fail('an already-downloaded playlist member must not be queued'); + }, + isOffline: false, + force: true, + ); + + expect(results, isEmpty); + expect(associated, ['jf-machine:episode-1']); + expect(client.playlistPageCalls, [(start: 0, size: 200)]); + expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue); + }); + test('collection sync rule pages through collection API instead of metadata children', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); @@ -377,12 +428,14 @@ void main() { ); final queued = []; + final associated = []; final executor = SyncRuleExecutor(database: db); final results = await executor.executeSyncRules( profileId: 'profile-a', serverManager: manager, downloads: const {}, metadata: {ruleKey: collection}, + associateDownload: (_, globalKey) async => associated.add(globalKey), queueSingleDownload: (item, client, {int mediaIndex = 0}) async { queued.add(item); return true; @@ -393,10 +446,57 @@ void main() { expect(results.single.queuedCount, 1); expect(queued.single.id, 'movie-1'); + expect(associated, ['plex-machine:movie-1']); + expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue); expect(client.collectionPageCalls, [(start: 0, size: 100)]); expect(client.fetchChildrenCalled, isFalse); }); + test('legacy list backfill associates active members without queueing', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final client = _CollectionPagingClient(); + manager.debugRegisterClientForTesting(client); + const ruleKey = 'profile-a|plex-machine:collection-1'; + final collection = testMediaItem( + id: 'collection-1', + backend: MediaBackend.plex, + kind: MediaKind.collection, + title: 'Collection', + serverId: 'plex-machine', + ); + await db.insertSyncRule( + profileId: 'profile-a', + serverId: ServerId('plex-machine'), + ratingKey: 'collection-1', + globalKey: ruleKey, + targetType: 'collection', + episodeCount: 0, + downloadFilter: SyncRuleFilter.all, + ); + final rule = (await db.getSyncRule(ruleKey))!; + final associated = []; + + final backfilled = await SyncRuleExecutor(database: db).backfillListRuleDownloadLinks( + rule: rule, + serverManager: manager, + downloads: const { + 'plex-machine:movie-1': DownloadProgress(globalKey: 'plex-machine:movie-1', status: DownloadStatus.completed), + }, + metadata: {ruleKey: collection}, + associateDownload: (_, globalKey) async => associated.add(globalKey), + ); + + expect(backfilled, isTrue); + expect(associated, ['plex-machine:movie-1']); + expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue); + }); + test('collectItemsForList accepts tracks and expands albums/artists', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(db.close); @@ -484,6 +584,47 @@ class _PlayableDescendantsClient implements MediaServerClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _PlaylistPagingClient implements MediaServerClient { + final playlistPageCalls = <({int? start, int? size})>[]; + + @override + ServerId get serverId => ServerId('jf-machine'); + + @override + String? get serverName => 'Jellyfin'; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + + @override + bool get isOfflineMode => false; + + @override + void close() {} + + @override + Future fetchItem(String id) async => null; + + @override + Future> fetchPlaylistPage(String id, {int? start, int? size, abort}) async { + playlistPageCalls.add((start: start, size: size)); + expect(id, 'playlist-1'); + return LibraryPage( + items: [ + testMediaItem(id: 'episode-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, title: 'Episode'), + ], + totalCount: 1, + offset: start ?? 0, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _CollectionPagingClient implements MediaServerClient { bool fetchChildrenCalled = false; final collectionPageCalls = <({int? start, int? size})>[];