From a715f4e26b369bc19e3dce3f6e6bf075a4d40834 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:45:03 +0200 Subject: [PATCH] feat: collection/playlist sync rules --- lib/database/app_database.dart | 18 +- lib/database/app_database.g.dart | 78 ++++- lib/database/tables.dart | 7 +- lib/i18n/da.i18n.json | 6 +- lib/i18n/de.i18n.json | 6 +- lib/i18n/en.i18n.json | 6 +- lib/i18n/es.i18n.json | 6 +- lib/i18n/fr.i18n.json | 6 +- lib/i18n/it.i18n.json | 6 +- lib/i18n/ja.i18n.json | 6 +- lib/i18n/ko.i18n.json | 6 +- lib/i18n/nb.i18n.json | 6 +- lib/i18n/nl.i18n.json | 6 +- lib/i18n/pl.i18n.json | 6 +- lib/i18n/pt.i18n.json | 6 +- lib/i18n/ru.i18n.json | 6 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_da.g.dart | 8 + lib/i18n/strings_de.g.dart | 8 + lib/i18n/strings_en.g.dart | 16 + lib/i18n/strings_es.g.dart | 8 + lib/i18n/strings_fr.g.dart | 8 + lib/i18n/strings_it.g.dart | 8 + lib/i18n/strings_ja.g.dart | 8 + lib/i18n/strings_ko.g.dart | 8 + lib/i18n/strings_nb.g.dart | 8 + lib/i18n/strings_nl.g.dart | 8 + lib/i18n/strings_pl.g.dart | 8 + lib/i18n/strings_pt.g.dart | 8 + lib/i18n/strings_ru.g.dart | 8 + lib/i18n/strings_sv.g.dart | 8 + lib/i18n/strings_zh.g.dart | 8 + lib/i18n/sv.i18n.json | 6 +- lib/i18n/zh.i18n.json | 6 +- lib/main.dart | 92 +++++- lib/providers/download_provider.dart | 100 ++++++- lib/screens/collection_detail_screen.dart | 57 ++++ lib/screens/downloads/sync_rules_screen.dart | 55 +++- .../playlist/playlist_detail_screen.dart | 52 +++- lib/services/download_manager_service.dart | 14 +- lib/services/sync_rule_executor.dart | 280 +++++++++++++++--- lib/utils/download_utils.dart | 194 +++++++++++- lib/widgets/media_context_menu.dart | 155 +++++++--- 43 files changed, 1178 insertions(+), 146 deletions(-) diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 2e33200f..0c42bac8 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -17,7 +17,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 11; + int get schemaVersion => 12; @override MigrationStrategy get migration { @@ -58,6 +58,14 @@ class AppDatabase extends _$AppDatabase { appLogger.w('enabled column may already exist: $e'); } } + if (from < 12) { + appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)'); + try { + await m.addColumn(syncRules, syncRules.downloadFilter); + } catch (e) { + appLogger.w('downloadFilter column may already exist: $e'); + } + } }, ); } @@ -232,6 +240,7 @@ class AppDatabase extends _$AppDatabase { required String targetType, required int episodeCount, int mediaIndex = 0, + String downloadFilter = 'unwatched', }) async { await into(syncRules).insertOnConflictUpdate( SyncRulesCompanion.insert( @@ -242,6 +251,7 @@ class AppDatabase extends _$AppDatabase { episodeCount: episodeCount, createdAt: DateTime.now().millisecondsSinceEpoch, mediaIndex: Value(mediaIndex), + downloadFilter: Value(downloadFilter), ), ); } @@ -252,6 +262,12 @@ class AppDatabase extends _$AppDatabase { )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount))); } + Future updateSyncRuleFilter(String globalKey, String downloadFilter) async { + await (update( + syncRules, + )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter))); + } + Future updateSyncRuleEnabled(String globalKey, bool enabled) async { await (update( syncRules, diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 4e549873..22f13d3d 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -2635,6 +2635,18 @@ class $SyncRulesTable extends SyncRules requiredDuringInsert: false, defaultValue: const Constant(0), ); + static const VerificationMeta _downloadFilterMeta = const VerificationMeta( + 'downloadFilter', + ); + @override + late final GeneratedColumn downloadFilter = GeneratedColumn( + 'download_filter', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('unwatched'), + ); @override List get $columns => [ id, @@ -2647,6 +2659,7 @@ class $SyncRulesTable extends SyncRules createdAt, lastExecutedAt, mediaIndex, + downloadFilter, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2735,6 +2748,15 @@ class $SyncRulesTable extends SyncRules mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta), ); } + if (data.containsKey('download_filter')) { + context.handle( + _downloadFilterMeta, + downloadFilter.isAcceptableOrUnknown( + data['download_filter']!, + _downloadFilterMeta, + ), + ); + } return context; } @@ -2784,6 +2806,10 @@ class $SyncRulesTable extends SyncRules DriftSqlType.int, data['${effectivePrefix}media_index'], )!, + downloadFilter: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}download_filter'], + )!, ); } @@ -2804,6 +2830,7 @@ class SyncRuleItem extends DataClass implements Insertable { final int createdAt; final int? lastExecutedAt; final int mediaIndex; + final String downloadFilter; const SyncRuleItem({ required this.id, required this.serverId, @@ -2815,6 +2842,7 @@ class SyncRuleItem extends DataClass implements Insertable { required this.createdAt, this.lastExecutedAt, required this.mediaIndex, + required this.downloadFilter, }); @override Map toColumns(bool nullToAbsent) { @@ -2831,6 +2859,7 @@ class SyncRuleItem extends DataClass implements Insertable { map['last_executed_at'] = Variable(lastExecutedAt); } map['media_index'] = Variable(mediaIndex); + map['download_filter'] = Variable(downloadFilter); return map; } @@ -2848,6 +2877,7 @@ class SyncRuleItem extends DataClass implements Insertable { ? const Value.absent() : Value(lastExecutedAt), mediaIndex: Value(mediaIndex), + downloadFilter: Value(downloadFilter), ); } @@ -2867,6 +2897,7 @@ class SyncRuleItem extends DataClass implements Insertable { createdAt: serializer.fromJson(json['createdAt']), lastExecutedAt: serializer.fromJson(json['lastExecutedAt']), mediaIndex: serializer.fromJson(json['mediaIndex']), + downloadFilter: serializer.fromJson(json['downloadFilter']), ); } @override @@ -2883,6 +2914,7 @@ class SyncRuleItem extends DataClass implements Insertable { 'createdAt': serializer.toJson(createdAt), 'lastExecutedAt': serializer.toJson(lastExecutedAt), 'mediaIndex': serializer.toJson(mediaIndex), + 'downloadFilter': serializer.toJson(downloadFilter), }; } @@ -2897,6 +2929,7 @@ class SyncRuleItem extends DataClass implements Insertable { int? createdAt, Value lastExecutedAt = const Value.absent(), int? mediaIndex, + String? downloadFilter, }) => SyncRuleItem( id: id ?? this.id, serverId: serverId ?? this.serverId, @@ -2910,6 +2943,7 @@ class SyncRuleItem extends DataClass implements Insertable { ? lastExecutedAt.value : this.lastExecutedAt, mediaIndex: mediaIndex ?? this.mediaIndex, + downloadFilter: downloadFilter ?? this.downloadFilter, ); SyncRuleItem copyWithCompanion(SyncRulesCompanion data) { return SyncRuleItem( @@ -2931,6 +2965,9 @@ class SyncRuleItem extends DataClass implements Insertable { mediaIndex: data.mediaIndex.present ? data.mediaIndex.value : this.mediaIndex, + downloadFilter: data.downloadFilter.present + ? data.downloadFilter.value + : this.downloadFilter, ); } @@ -2946,7 +2983,8 @@ class SyncRuleItem extends DataClass implements Insertable { ..write('enabled: $enabled, ') ..write('createdAt: $createdAt, ') ..write('lastExecutedAt: $lastExecutedAt, ') - ..write('mediaIndex: $mediaIndex') + ..write('mediaIndex: $mediaIndex, ') + ..write('downloadFilter: $downloadFilter') ..write(')')) .toString(); } @@ -2963,6 +3001,7 @@ class SyncRuleItem extends DataClass implements Insertable { createdAt, lastExecutedAt, mediaIndex, + downloadFilter, ); @override bool operator ==(Object other) => @@ -2977,7 +3016,8 @@ class SyncRuleItem extends DataClass implements Insertable { other.enabled == this.enabled && other.createdAt == this.createdAt && other.lastExecutedAt == this.lastExecutedAt && - other.mediaIndex == this.mediaIndex); + other.mediaIndex == this.mediaIndex && + other.downloadFilter == this.downloadFilter); } class SyncRulesCompanion extends UpdateCompanion { @@ -2991,6 +3031,7 @@ class SyncRulesCompanion extends UpdateCompanion { final Value createdAt; final Value lastExecutedAt; final Value mediaIndex; + final Value downloadFilter; const SyncRulesCompanion({ this.id = const Value.absent(), this.serverId = const Value.absent(), @@ -3002,6 +3043,7 @@ class SyncRulesCompanion extends UpdateCompanion { this.createdAt = const Value.absent(), this.lastExecutedAt = const Value.absent(), this.mediaIndex = const Value.absent(), + this.downloadFilter = const Value.absent(), }); SyncRulesCompanion.insert({ this.id = const Value.absent(), @@ -3014,6 +3056,7 @@ class SyncRulesCompanion extends UpdateCompanion { required int createdAt, this.lastExecutedAt = const Value.absent(), this.mediaIndex = const Value.absent(), + this.downloadFilter = const Value.absent(), }) : serverId = Value(serverId), ratingKey = Value(ratingKey), globalKey = Value(globalKey), @@ -3031,6 +3074,7 @@ class SyncRulesCompanion extends UpdateCompanion { Expression? createdAt, Expression? lastExecutedAt, Expression? mediaIndex, + Expression? downloadFilter, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -3043,6 +3087,7 @@ class SyncRulesCompanion extends UpdateCompanion { if (createdAt != null) 'created_at': createdAt, if (lastExecutedAt != null) 'last_executed_at': lastExecutedAt, if (mediaIndex != null) 'media_index': mediaIndex, + if (downloadFilter != null) 'download_filter': downloadFilter, }); } @@ -3057,6 +3102,7 @@ class SyncRulesCompanion extends UpdateCompanion { Value? createdAt, Value? lastExecutedAt, Value? mediaIndex, + Value? downloadFilter, }) { return SyncRulesCompanion( id: id ?? this.id, @@ -3069,6 +3115,7 @@ class SyncRulesCompanion extends UpdateCompanion { createdAt: createdAt ?? this.createdAt, lastExecutedAt: lastExecutedAt ?? this.lastExecutedAt, mediaIndex: mediaIndex ?? this.mediaIndex, + downloadFilter: downloadFilter ?? this.downloadFilter, ); } @@ -3105,6 +3152,9 @@ class SyncRulesCompanion extends UpdateCompanion { if (mediaIndex.present) { map['media_index'] = Variable(mediaIndex.value); } + if (downloadFilter.present) { + map['download_filter'] = Variable(downloadFilter.value); + } return map; } @@ -3120,7 +3170,8 @@ class SyncRulesCompanion extends UpdateCompanion { ..write('enabled: $enabled, ') ..write('createdAt: $createdAt, ') ..write('lastExecutedAt: $lastExecutedAt, ') - ..write('mediaIndex: $mediaIndex') + ..write('mediaIndex: $mediaIndex, ') + ..write('downloadFilter: $downloadFilter') ..write(')')) .toString(); } @@ -4386,6 +4437,7 @@ typedef $$SyncRulesTableCreateCompanionBuilder = required int createdAt, Value lastExecutedAt, Value mediaIndex, + Value downloadFilter, }); typedef $$SyncRulesTableUpdateCompanionBuilder = SyncRulesCompanion Function({ @@ -4399,6 +4451,7 @@ typedef $$SyncRulesTableUpdateCompanionBuilder = Value createdAt, Value lastExecutedAt, Value mediaIndex, + Value downloadFilter, }); class $$SyncRulesTableFilterComposer @@ -4459,6 +4512,11 @@ class $$SyncRulesTableFilterComposer column: $table.mediaIndex, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get downloadFilter => $composableBuilder( + column: $table.downloadFilter, + builder: (column) => ColumnFilters(column), + ); } class $$SyncRulesTableOrderingComposer @@ -4519,6 +4577,11 @@ class $$SyncRulesTableOrderingComposer column: $table.mediaIndex, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get downloadFilter => $composableBuilder( + column: $table.downloadFilter, + builder: (column) => ColumnOrderings(column), + ); } class $$SyncRulesTableAnnotationComposer @@ -4567,6 +4630,11 @@ class $$SyncRulesTableAnnotationComposer column: $table.mediaIndex, builder: (column) => column, ); + + GeneratedColumn get downloadFilter => $composableBuilder( + column: $table.downloadFilter, + builder: (column) => column, + ); } class $$SyncRulesTableTableManager @@ -4610,6 +4678,7 @@ class $$SyncRulesTableTableManager Value createdAt = const Value.absent(), Value lastExecutedAt = const Value.absent(), Value mediaIndex = const Value.absent(), + Value downloadFilter = const Value.absent(), }) => SyncRulesCompanion( id: id, serverId: serverId, @@ -4621,6 +4690,7 @@ class $$SyncRulesTableTableManager createdAt: createdAt, lastExecutedAt: lastExecutedAt, mediaIndex: mediaIndex, + downloadFilter: downloadFilter, ), createCompanionCallback: ({ @@ -4634,6 +4704,7 @@ class $$SyncRulesTableTableManager required int createdAt, Value lastExecutedAt = const Value.absent(), Value mediaIndex = const Value.absent(), + Value downloadFilter = const Value.absent(), }) => SyncRulesCompanion.insert( id: id, serverId: serverId, @@ -4645,6 +4716,7 @@ class $$SyncRulesTableTableManager createdAt: createdAt, lastExecutedAt: lastExecutedAt, mediaIndex: mediaIndex, + downloadFilter: downloadFilter, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) diff --git a/lib/database/tables.dart b/lib/database/tables.dart index d7e153c7..5cdec015 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -53,20 +53,21 @@ class DownloadedMedia extends Table { /// Persistent sync rules for auto-downloading unwatched episodes. /// -/// Each rule keeps a rolling window of N unwatched episodes for a show/season. -/// When watched episodes are removed, new unwatched ones are queued. +/// Each rule keeps a rolling window of N unwatched episodes for a show/season, +/// or mirrors the current contents of a collection/playlist. @DataClassName('SyncRuleItem') class SyncRules extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get serverId => text()(); TextColumn get ratingKey => text()(); TextColumn get globalKey => text().unique()(); - TextColumn get targetType => text()(); // 'show' or 'season' + TextColumn get targetType => text()(); // 'show', 'season', 'collection', 'playlist' IntColumn get episodeCount => integer()(); BoolColumn get enabled => boolean().withDefault(const Constant(true))(); IntColumn get createdAt => integer()(); IntColumn get lastExecutedAt => integer().nullable()(); IntColumn get mediaIndex => integer().withDefault(const Constant(0))(); + TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))(); } /// Queue for offline watch progress and manual watch actions. diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 78a62810..cfb0d996 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Synkroniseringsregler", "noSyncRules": "Ingen synkroniseringsregler", "manageSyncRule": "Administrer synkronisering", - "editEpisodeCount": "Antal episoder" + "editEpisodeCount": "Antal episoder", + "editSyncFilter": "Synkroniseringsfilter", + "syncAllItems": "Synkroniserer alle elementer", + "syncUnwatchedItems": "Synkroniserer usete elementer", + "syncRuleListCreated": "Synkroniseringsregel oprettet" }, "shaders": { "title": "Shadere", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index e34146c6..ebc2a56c 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Sync-Regeln", "noSyncRules": "Keine Sync-Regeln", "manageSyncRule": "Synchronisierung verwalten", - "editEpisodeCount": "Episodenanzahl" + "editEpisodeCount": "Episodenanzahl", + "editSyncFilter": "Synchronisierungsfilter", + "syncAllItems": "Alle Einträge synchronisieren", + "syncUnwatchedItems": "Ungesehene Einträge synchronisieren", + "syncRuleListCreated": "Sync-Regel erstellt" }, "shaders": { "title": "Shader", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index b96f4318..e4da2af2 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Sync rules", "noSyncRules": "No sync rules", "manageSyncRule": "Manage sync", - "editEpisodeCount": "Episode count" + "editEpisodeCount": "Episode count", + "editSyncFilter": "Sync filter", + "syncAllItems": "Syncing all items", + "syncUnwatchedItems": "Syncing unwatched items", + "syncRuleListCreated": "Sync rule created" }, "shaders": { "title": "Shaders", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 8880a75e..446fae0d 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Reglas de sincronización", "noSyncRules": "Sin reglas de sincronización", "manageSyncRule": "Gestionar sincronización", - "editEpisodeCount": "Número de episodios" + "editEpisodeCount": "Número de episodios", + "editSyncFilter": "Filtro de sincronización", + "syncAllItems": "Sincronizando todos los elementos", + "syncUnwatchedItems": "Sincronizando elementos no vistos", + "syncRuleListCreated": "Regla de sincronización creada" }, "shaders": { "title": "Shaders", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 914f43d9..2c5d3e03 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Règles de synchronisation", "noSyncRules": "Aucune règle de synchronisation", "manageSyncRule": "Gérer la synchronisation", - "editEpisodeCount": "Nombre d’épisodes" + "editEpisodeCount": "Nombre d’épisodes", + "editSyncFilter": "Filtre de synchronisation", + "syncAllItems": "Synchronisation de tous les éléments", + "syncUnwatchedItems": "Synchronisation des éléments non vus", + "syncRuleListCreated": "Règle de synchronisation créée" }, "shaders": { "title": "Shaders", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index e5bbc40b..261b640d 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Regole di sincronizzazione", "noSyncRules": "Nessuna regola di sincronizzazione", "manageSyncRule": "Gestisci sincronizzazione", - "editEpisodeCount": "Numero di episodi" + "editEpisodeCount": "Numero di episodi", + "editSyncFilter": "Filtro di sincronizzazione", + "syncAllItems": "Sincronizzazione di tutti gli elementi", + "syncUnwatchedItems": "Sincronizzazione degli elementi non visti", + "syncRuleListCreated": "Regola di sincronizzazione creata" }, "shaders": { "title": "Shader", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 86a162fd..aae25db9 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "同期ルール", "noSyncRules": "同期ルールなし", "manageSyncRule": "同期を管理", - "editEpisodeCount": "エピソード数" + "editEpisodeCount": "エピソード数", + "editSyncFilter": "同期フィルター", + "syncAllItems": "すべてのアイテムを同期中", + "syncUnwatchedItems": "未視聴のアイテムを同期中", + "syncRuleListCreated": "同期ルールを作成しました" }, "shaders": { "title": "シェーダー", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 6e8ca137..f60a45c6 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "동기화 규칙", "noSyncRules": "동기화 규칙 없음", "manageSyncRule": "동기화 관리", - "editEpisodeCount": "에피소드 수" + "editEpisodeCount": "에피소드 수", + "editSyncFilter": "동기화 필터", + "syncAllItems": "모든 항목 동기화 중", + "syncUnwatchedItems": "시청하지 않은 항목 동기화 중", + "syncRuleListCreated": "동기화 규칙이 생성되었습니다" }, "shaders": { "title": "셰이더", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 56708ac1..9887c304 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Synkroniseringsregler", "noSyncRules": "Ingen synkroniseringsregler", "manageSyncRule": "Administrer synkronisering", - "editEpisodeCount": "Antall episoder" + "editEpisodeCount": "Antall episoder", + "editSyncFilter": "Synkroniseringsfilter", + "syncAllItems": "Synkroniserer alle elementer", + "syncUnwatchedItems": "Synkroniserer usette elementer", + "syncRuleListCreated": "Synkroniseringsregel opprettet" }, "shaders": { "title": "Shadere", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 525043e3..c359771c 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Synchronisatieregels", "noSyncRules": "Geen synchronisatieregels", "manageSyncRule": "Synchronisatie beheren", - "editEpisodeCount": "Aantal afleveringen" + "editEpisodeCount": "Aantal afleveringen", + "editSyncFilter": "Synchronisatiefilter", + "syncAllItems": "Alle items synchroniseren", + "syncUnwatchedItems": "Ongekeken items synchroniseren", + "syncRuleListCreated": "Synchronisatieregel aangemaakt" }, "shaders": { "title": "Shaders", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 5265854b..248e0f8c 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Reguły synchronizacji", "noSyncRules": "Brak reguł synchronizacji", "manageSyncRule": "Zarządzaj synchronizacją", - "editEpisodeCount": "Liczba odcinków" + "editEpisodeCount": "Liczba odcinków", + "editSyncFilter": "Filtr synchronizacji", + "syncAllItems": "Synchronizuję wszystkie elementy", + "syncUnwatchedItems": "Synchronizuję nieobejrzane elementy", + "syncRuleListCreated": "Utworzono regułę synchronizacji" }, "shaders": { "title": "Shadery", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index fb2ed585..d782f5f2 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Regras de sincronização", "noSyncRules": "Nenhuma regra de sincronização", "manageSyncRule": "Gerenciar sincronização", - "editEpisodeCount": "Número de episódios" + "editEpisodeCount": "Número de episódios", + "editSyncFilter": "Filtro de sincronização", + "syncAllItems": "Sincronizando todos os itens", + "syncUnwatchedItems": "Sincronizando itens não vistos", + "syncRuleListCreated": "Regra de sincronização criada" }, "shaders": { "title": "Shaders", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 3f0fa550..ffbe7d7a 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Правила синхронизации", "noSyncRules": "Нет правил синхронизации", "manageSyncRule": "Управление синхронизацией", - "editEpisodeCount": "Количество эпизодов" + "editEpisodeCount": "Количество эпизодов", + "editSyncFilter": "Фильтр синхронизации", + "syncAllItems": "Синхронизация всех элементов", + "syncUnwatchedItems": "Синхронизация непросмотренных элементов", + "syncRuleListCreated": "Правило синхронизации создано" }, "shaders": { "title": "Шейдеры", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 75dbba3d..a8e9480c 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 13200 (880 per locale) +/// Strings: 13260 (884 per locale) /// -/// Built on 2026-04-19 at 12:28 UTC +/// Built on 2026-04-19 at 14:14 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 6c524e0e..17f84c74 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn { @override String get noSyncRules => 'Ingen synkroniseringsregler'; @override String get manageSyncRule => 'Administrer synkronisering'; @override String get editEpisodeCount => 'Antal episoder'; + @override String get editSyncFilter => 'Synkroniseringsfilter'; + @override String get syncAllItems => 'Synkroniserer alle elementer'; + @override String get syncUnwatchedItems => 'Synkroniserer usete elementer'; + @override String get syncRuleListCreated => 'Synkroniseringsregel oprettet'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsDa { 'downloads.noSyncRules' => 'Ingen synkroniseringsregler', 'downloads.manageSyncRule' => 'Administrer synkronisering', 'downloads.editEpisodeCount' => 'Antal episoder', + 'downloads.editSyncFilter' => 'Synkroniseringsfilter', + 'downloads.syncAllItems' => 'Synkroniserer alle elementer', + 'downloads.syncUnwatchedItems' => 'Synkroniserer usete elementer', + 'downloads.syncRuleListCreated' => 'Synkroniseringsregel oprettet', 'shaders.title' => 'Shadere', 'shaders.noShaderDescription' => 'Ingen videoforbedring', 'shaders.nvscalerDescription' => 'NVIDIA-billedskalering for skarpere video', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 622e027b..37904fb1 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn { @override String get noSyncRules => 'Keine Sync-Regeln'; @override String get manageSyncRule => 'Synchronisierung verwalten'; @override String get editEpisodeCount => 'Episodenanzahl'; + @override String get editSyncFilter => 'Synchronisierungsfilter'; + @override String get syncAllItems => 'Alle Einträge synchronisieren'; + @override String get syncUnwatchedItems => 'Ungesehene Einträge synchronisieren'; + @override String get syncRuleListCreated => 'Sync-Regel erstellt'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsDe { 'downloads.noSyncRules' => 'Keine Sync-Regeln', 'downloads.manageSyncRule' => 'Synchronisierung verwalten', 'downloads.editEpisodeCount' => 'Episodenanzahl', + 'downloads.editSyncFilter' => 'Synchronisierungsfilter', + 'downloads.syncAllItems' => 'Alle Einträge synchronisieren', + 'downloads.syncUnwatchedItems' => 'Ungesehene Einträge synchronisieren', + 'downloads.syncRuleListCreated' => 'Sync-Regel erstellt', 'shaders.title' => 'Shader', 'shaders.noShaderDescription' => 'Keine Videoverbesserung', 'shaders.nvscalerDescription' => 'NVIDIA-Bildskalierung für schärferes Video', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 7d3c49e0..5e5ce6e3 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2348,6 +2348,18 @@ class TranslationsDownloadsEn { /// en: 'Episode count' String get editEpisodeCount => 'Episode count'; + + /// en: 'Sync filter' + String get editSyncFilter => 'Sync filter'; + + /// en: 'Syncing all items' + String get syncAllItems => 'Syncing all items'; + + /// en: 'Syncing unwatched items' + String get syncUnwatchedItems => 'Syncing unwatched items'; + + /// en: 'Sync rule created' + String get syncRuleListCreated => 'Sync rule created'; } // Path: shaders @@ -3851,6 +3863,10 @@ extension on Translations { 'downloads.noSyncRules' => 'No sync rules', 'downloads.manageSyncRule' => 'Manage sync', 'downloads.editEpisodeCount' => 'Episode count', + 'downloads.editSyncFilter' => 'Sync filter', + 'downloads.syncAllItems' => 'Syncing all items', + 'downloads.syncUnwatchedItems' => 'Syncing unwatched items', + 'downloads.syncRuleListCreated' => 'Sync rule created', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'No video enhancement', 'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index bfb13769..31fefe88 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn { @override String get noSyncRules => 'Sin reglas de sincronización'; @override String get manageSyncRule => 'Gestionar sincronización'; @override String get editEpisodeCount => 'Número de episodios'; + @override String get editSyncFilter => 'Filtro de sincronización'; + @override String get syncAllItems => 'Sincronizando todos los elementos'; + @override String get syncUnwatchedItems => 'Sincronizando elementos no vistos'; + @override String get syncRuleListCreated => 'Regla de sincronización creada'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsEs { 'downloads.noSyncRules' => 'Sin reglas de sincronización', 'downloads.manageSyncRule' => 'Gestionar sincronización', 'downloads.editEpisodeCount' => 'Número de episodios', + 'downloads.editSyncFilter' => 'Filtro de sincronización', + 'downloads.syncAllItems' => 'Sincronizando todos los elementos', + 'downloads.syncUnwatchedItems' => 'Sincronizando elementos no vistos', + 'downloads.syncRuleListCreated' => 'Regla de sincronización creada', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Sin mejora de video', 'shaders.nvscalerDescription' => 'Escalado de imagen NVIDIA para un video más nítido', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 9982af2d..a284ab1f 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn { @override String get noSyncRules => 'Aucune règle de synchronisation'; @override String get manageSyncRule => 'Gérer la synchronisation'; @override String get editEpisodeCount => 'Nombre d’épisodes'; + @override String get editSyncFilter => 'Filtre de synchronisation'; + @override String get syncAllItems => 'Synchronisation de tous les éléments'; + @override String get syncUnwatchedItems => 'Synchronisation des éléments non vus'; + @override String get syncRuleListCreated => 'Règle de synchronisation créée'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsFr { 'downloads.noSyncRules' => 'Aucune règle de synchronisation', 'downloads.manageSyncRule' => 'Gérer la synchronisation', 'downloads.editEpisodeCount' => 'Nombre d’épisodes', + 'downloads.editSyncFilter' => 'Filtre de synchronisation', + 'downloads.syncAllItems' => 'Synchronisation de tous les éléments', + 'downloads.syncUnwatchedItems' => 'Synchronisation des éléments non vus', + 'downloads.syncRuleListCreated' => 'Règle de synchronisation créée', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Aucune amélioration vidéo', 'shaders.nvscalerDescription' => 'Mise à l\'échelle NVIDIA pour une vidéo plus nette', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index a5b1f0cb..6fa08014 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn { @override String get noSyncRules => 'Nessuna regola di sincronizzazione'; @override String get manageSyncRule => 'Gestisci sincronizzazione'; @override String get editEpisodeCount => 'Numero di episodi'; + @override String get editSyncFilter => 'Filtro di sincronizzazione'; + @override String get syncAllItems => 'Sincronizzazione di tutti gli elementi'; + @override String get syncUnwatchedItems => 'Sincronizzazione degli elementi non visti'; + @override String get syncRuleListCreated => 'Regola di sincronizzazione creata'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsIt { 'downloads.noSyncRules' => 'Nessuna regola di sincronizzazione', 'downloads.manageSyncRule' => 'Gestisci sincronizzazione', 'downloads.editEpisodeCount' => 'Numero di episodi', + 'downloads.editSyncFilter' => 'Filtro di sincronizzazione', + 'downloads.syncAllItems' => 'Sincronizzazione di tutti gli elementi', + 'downloads.syncUnwatchedItems' => 'Sincronizzazione degli elementi non visti', + 'downloads.syncRuleListCreated' => 'Regola di sincronizzazione creata', 'shaders.title' => 'Shader', 'shaders.noShaderDescription' => 'Nessun miglioramento video', 'shaders.nvscalerDescription' => 'Ridimensionamento NVIDIA per video più nitido', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index f4395d21..77e65295 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn { @override String get noSyncRules => '同期ルールなし'; @override String get manageSyncRule => '同期を管理'; @override String get editEpisodeCount => 'エピソード数'; + @override String get editSyncFilter => '同期フィルター'; + @override String get syncAllItems => 'すべてのアイテムを同期中'; + @override String get syncUnwatchedItems => '未視聴のアイテムを同期中'; + @override String get syncRuleListCreated => '同期ルールを作成しました'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsJa { 'downloads.noSyncRules' => '同期ルールなし', 'downloads.manageSyncRule' => '同期を管理', 'downloads.editEpisodeCount' => 'エピソード数', + 'downloads.editSyncFilter' => '同期フィルター', + 'downloads.syncAllItems' => 'すべてのアイテムを同期中', + 'downloads.syncUnwatchedItems' => '未視聴のアイテムを同期中', + 'downloads.syncRuleListCreated' => '同期ルールを作成しました', 'shaders.title' => 'シェーダー', 'shaders.noShaderDescription' => '映像補正なし', 'shaders.nvscalerDescription' => 'よりシャープな映像のためのNVIDIA画像スケーリング', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 5d774be6..12369054 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn { @override String get noSyncRules => '동기화 규칙 없음'; @override String get manageSyncRule => '동기화 관리'; @override String get editEpisodeCount => '에피소드 수'; + @override String get editSyncFilter => '동기화 필터'; + @override String get syncAllItems => '모든 항목 동기화 중'; + @override String get syncUnwatchedItems => '시청하지 않은 항목 동기화 중'; + @override String get syncRuleListCreated => '동기화 규칙이 생성되었습니다'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsKo { 'downloads.noSyncRules' => '동기화 규칙 없음', 'downloads.manageSyncRule' => '동기화 관리', 'downloads.editEpisodeCount' => '에피소드 수', + 'downloads.editSyncFilter' => '동기화 필터', + 'downloads.syncAllItems' => '모든 항목 동기화 중', + 'downloads.syncUnwatchedItems' => '시청하지 않은 항목 동기화 중', + 'downloads.syncRuleListCreated' => '동기화 규칙이 생성되었습니다', 'shaders.title' => '셰이더', 'shaders.noShaderDescription' => '비디오 향상 없음', 'shaders.nvscalerDescription' => '더 선명한 비디오를 위한 NVIDIA 이미지 스케일링', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 8f3cbd44..8fdb960f 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn { @override String get noSyncRules => 'Ingen synkroniseringsregler'; @override String get manageSyncRule => 'Administrer synkronisering'; @override String get editEpisodeCount => 'Antall episoder'; + @override String get editSyncFilter => 'Synkroniseringsfilter'; + @override String get syncAllItems => 'Synkroniserer alle elementer'; + @override String get syncUnwatchedItems => 'Synkroniserer usette elementer'; + @override String get syncRuleListCreated => 'Synkroniseringsregel opprettet'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsNb { 'downloads.noSyncRules' => 'Ingen synkroniseringsregler', 'downloads.manageSyncRule' => 'Administrer synkronisering', 'downloads.editEpisodeCount' => 'Antall episoder', + 'downloads.editSyncFilter' => 'Synkroniseringsfilter', + 'downloads.syncAllItems' => 'Synkroniserer alle elementer', + 'downloads.syncUnwatchedItems' => 'Synkroniserer usette elementer', + 'downloads.syncRuleListCreated' => 'Synkroniseringsregel opprettet', 'shaders.title' => 'Shadere', 'shaders.noShaderDescription' => 'Ingen videoforbedring', 'shaders.nvscalerDescription' => 'NVIDIA bildeskalering for skarpere video', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 7a808fb4..31a57f1c 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn { @override String get noSyncRules => 'Geen synchronisatieregels'; @override String get manageSyncRule => 'Synchronisatie beheren'; @override String get editEpisodeCount => 'Aantal afleveringen'; + @override String get editSyncFilter => 'Synchronisatiefilter'; + @override String get syncAllItems => 'Alle items synchroniseren'; + @override String get syncUnwatchedItems => 'Ongekeken items synchroniseren'; + @override String get syncRuleListCreated => 'Synchronisatieregel aangemaakt'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsNl { 'downloads.noSyncRules' => 'Geen synchronisatieregels', 'downloads.manageSyncRule' => 'Synchronisatie beheren', 'downloads.editEpisodeCount' => 'Aantal afleveringen', + 'downloads.editSyncFilter' => 'Synchronisatiefilter', + 'downloads.syncAllItems' => 'Alle items synchroniseren', + 'downloads.syncUnwatchedItems' => 'Ongekeken items synchroniseren', + 'downloads.syncRuleListCreated' => 'Synchronisatieregel aangemaakt', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Geen videoverbetering', 'shaders.nvscalerDescription' => 'NVIDIA-beeldschaling voor scherpere video', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 438f7f75..be5ced98 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn { @override String get noSyncRules => 'Brak reguł synchronizacji'; @override String get manageSyncRule => 'Zarządzaj synchronizacją'; @override String get editEpisodeCount => 'Liczba odcinków'; + @override String get editSyncFilter => 'Filtr synchronizacji'; + @override String get syncAllItems => 'Synchronizuję wszystkie elementy'; + @override String get syncUnwatchedItems => 'Synchronizuję nieobejrzane elementy'; + @override String get syncRuleListCreated => 'Utworzono regułę synchronizacji'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsPl { 'downloads.noSyncRules' => 'Brak reguł synchronizacji', 'downloads.manageSyncRule' => 'Zarządzaj synchronizacją', 'downloads.editEpisodeCount' => 'Liczba odcinków', + 'downloads.editSyncFilter' => 'Filtr synchronizacji', + 'downloads.syncAllItems' => 'Synchronizuję wszystkie elementy', + 'downloads.syncUnwatchedItems' => 'Synchronizuję nieobejrzane elementy', + 'downloads.syncRuleListCreated' => 'Utworzono regułę synchronizacji', 'shaders.title' => 'Shadery', 'shaders.noShaderDescription' => 'Bez ulepszenia wideo', 'shaders.nvscalerDescription' => 'Skalowanie obrazu NVIDIA dla ostrzejszego wideo', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 898929c0..93519c13 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn { @override String get noSyncRules => 'Nenhuma regra de sincronização'; @override String get manageSyncRule => 'Gerenciar sincronização'; @override String get editEpisodeCount => 'Número de episódios'; + @override String get editSyncFilter => 'Filtro de sincronização'; + @override String get syncAllItems => 'Sincronizando todos os itens'; + @override String get syncUnwatchedItems => 'Sincronizando itens não vistos'; + @override String get syncRuleListCreated => 'Regra de sincronização criada'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsPt { 'downloads.noSyncRules' => 'Nenhuma regra de sincronização', 'downloads.manageSyncRule' => 'Gerenciar sincronização', 'downloads.editEpisodeCount' => 'Número de episódios', + 'downloads.editSyncFilter' => 'Filtro de sincronização', + 'downloads.syncAllItems' => 'Sincronizando todos os itens', + 'downloads.syncUnwatchedItems' => 'Sincronizando itens não vistos', + 'downloads.syncRuleListCreated' => 'Regra de sincronização criada', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo', 'shaders.nvscalerDescription' => 'Escalonamento de imagem NVIDIA para vídeo mais nítido', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 68748c2c..dccf35dd 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn { @override String get noSyncRules => 'Нет правил синхронизации'; @override String get manageSyncRule => 'Управление синхронизацией'; @override String get editEpisodeCount => 'Количество эпизодов'; + @override String get editSyncFilter => 'Фильтр синхронизации'; + @override String get syncAllItems => 'Синхронизация всех элементов'; + @override String get syncUnwatchedItems => 'Синхронизация непросмотренных элементов'; + @override String get syncRuleListCreated => 'Правило синхронизации создано'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsRu { 'downloads.noSyncRules' => 'Нет правил синхронизации', 'downloads.manageSyncRule' => 'Управление синхронизацией', 'downloads.editEpisodeCount' => 'Количество эпизодов', + 'downloads.editSyncFilter' => 'Фильтр синхронизации', + 'downloads.syncAllItems' => 'Синхронизация всех элементов', + 'downloads.syncUnwatchedItems' => 'Синхронизация непросмотренных элементов', + 'downloads.syncRuleListCreated' => 'Правило синхронизации создано', 'shaders.title' => 'Шейдеры', 'shaders.noShaderDescription' => 'Без улучшения видео', 'shaders.nvscalerDescription' => 'Масштабирование NVIDIA для более чёткого видео', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index ff169c3f..40389149 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn { @override String get noSyncRules => 'Inga synkregler'; @override String get manageSyncRule => 'Hantera synkronisering'; @override String get editEpisodeCount => 'Antal avsnitt'; + @override String get editSyncFilter => 'Synkroniseringsfilter'; + @override String get syncAllItems => 'Synkroniserar alla objekt'; + @override String get syncUnwatchedItems => 'Synkroniserar osedda objekt'; + @override String get syncRuleListCreated => 'Synkroniseringsregel skapad'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsSv { 'downloads.noSyncRules' => 'Inga synkregler', 'downloads.manageSyncRule' => 'Hantera synkronisering', 'downloads.editEpisodeCount' => 'Antal avsnitt', + 'downloads.editSyncFilter' => 'Synkroniseringsfilter', + 'downloads.syncAllItems' => 'Synkroniserar alla objekt', + 'downloads.syncUnwatchedItems' => 'Synkroniserar osedda objekt', + 'downloads.syncRuleListCreated' => 'Synkroniseringsregel skapad', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Ingen videoförbättring', 'shaders.nvscalerDescription' => 'NVIDIA-bildskalning för skarpare video', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index edf1a8dc..ab853d00 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1026,6 +1026,10 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn { @override String get noSyncRules => '没有同步规则'; @override String get manageSyncRule => '管理同步'; @override String get editEpisodeCount => '剧集数量'; + @override String get editSyncFilter => '同步筛选'; + @override String get syncAllItems => '同步所有项目'; + @override String get syncUnwatchedItems => '同步未观看项目'; + @override String get syncRuleListCreated => '同步规则已创建'; } // Path: shaders @@ -2084,6 +2088,10 @@ extension on TranslationsZh { 'downloads.noSyncRules' => '没有同步规则', 'downloads.manageSyncRule' => '管理同步', 'downloads.editEpisodeCount' => '剧集数量', + 'downloads.editSyncFilter' => '同步筛选', + 'downloads.syncAllItems' => '同步所有项目', + 'downloads.syncUnwatchedItems' => '同步未观看项目', + 'downloads.syncRuleListCreated' => '同步规则已创建', 'shaders.title' => '着色器', 'shaders.noShaderDescription' => '无视频增强', 'shaders.nvscalerDescription' => 'NVIDIA 图像缩放,使视频更清晰', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 4cefd7a3..d965db3a 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "Synkregler", "noSyncRules": "Inga synkregler", "manageSyncRule": "Hantera synkronisering", - "editEpisodeCount": "Antal avsnitt" + "editEpisodeCount": "Antal avsnitt", + "editSyncFilter": "Synkroniseringsfilter", + "syncAllItems": "Synkroniserar alla objekt", + "syncUnwatchedItems": "Synkroniserar osedda objekt", + "syncRuleListCreated": "Synkroniseringsregel skapad" }, "shaders": { "title": "Shaders", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index a1a20e44..75fd34aa 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -764,7 +764,11 @@ "activeSyncRules": "同步规则", "noSyncRules": "没有同步规则", "manageSyncRule": "管理同步", - "editEpisodeCount": "剧集数量" + "editEpisodeCount": "剧集数量", + "editSyncFilter": "同步筛选", + "syncAllItems": "同步所有项目", + "syncUnwatchedItems": "同步未观看项目", + "syncRuleListCreated": "同步规则已创建" }, "shaders": { "title": "着色器", diff --git a/lib/main.dart b/lib/main.dart index 404dff54..f1d1cb1f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,6 +51,7 @@ import 'database/app_database.dart'; import 'screens/video_player_screen.dart'; import 'utils/app_logger.dart'; import 'utils/orientation_helper.dart'; +import 'utils/global_key_utils.dart'; import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; import 'focus/input_mode_tracker.dart'; @@ -371,8 +372,11 @@ class _MainAppState extends State with WidgetsBindingObserver { late final OfflineWatchSyncService _offlineWatchSyncService; late final AppLifecycleListener _appLifecycleListener; StreamSubscription? _watchStateSubscription; + StreamSubscription>? _connectivitySubscription; Timer? _syncDebounce; + final Set _pendingSyncKeys = {}; bool _isAutoDeleteRunning = false; + bool _lastConnectivityWasWifi = false; /// Last time server health probes ran from a resume event (cooldown for desktop) DateTime _lastResumeProbe = DateTime(0); @@ -429,6 +433,7 @@ class _MainAppState extends State with WidgetsBindingObserver { void dispose() { _syncDebounce?.cancel(); _watchStateSubscription?.cancel(); + _connectivitySubscription?.cancel(); _memoryCheckTimer?.cancel(); _appLifecycleListener.dispose(); WidgetsBinding.instance.removeObserver(this); @@ -447,9 +452,52 @@ class _MainAppState extends State with WidgetsBindingObserver { PaintingBinding.instance.imageCache.clearLiveImages(); } - /// Auto-delete watched downloads and execute sync rules. - /// Shared by onWatchStatesRefreshed and the WatchStateNotifier listener. - Future _autoDeleteAndSync(DownloadProvider downloadProvider) async { + /// Fires [_autoDeleteAndSync] on each WiFi/Ethernet reconnect so rules run + /// as soon as the device is back online. Rapid flapping is bounded by the + /// executor's cooldown. + void _startConnectivitySyncTrigger(DownloadProvider downloadProvider) { + Future setup() async { + try { + final initial = await Connectivity().checkConnectivity(); + _lastConnectivityWasWifi = _hasWifiOrEthernet(initial); + } catch (e) { + appLogger.w('Initial connectivity read failed, defaulting to false: $e'); + _lastConnectivityWasWifi = false; + } + + try { + _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { + final hasWifi = _hasWifiOrEthernet(results); + final transitioned = hasWifi && !_lastConnectivityWasWifi; + _lastConnectivityWasWifi = hasWifi; + if (transitioned) { + appLogger.d('Connectivity moved onto WiFi/Ethernet — triggering sync pass'); + _autoDeleteAndSync(downloadProvider); + } + }); + } catch (e) { + appLogger.w('Could not subscribe to connectivity changes: $e'); + } + } + + setup(); + } + + static bool _hasWifiOrEthernet(List results) => + results.contains(ConnectivityResult.wifi) || results.contains(ConnectivityResult.ethernet); + + /// Run auto-delete (if enabled) and then a sync-rule pass. + /// + /// When [targetKeys] is non-null, only those rules are re-evaluated + /// (cooldown doesn't apply — targeted runs are always "we know this + /// changed"). When null, every rule runs via the executor, with [force] + /// gating the cooldown: `true` for user-initiated drains, `false` for + /// background probes like a connectivity reconnect. + Future _autoDeleteAndSync( + DownloadProvider downloadProvider, { + List? targetKeys, + bool force = false, + }) async { if (_isAutoDeleteRunning) return; _isAutoDeleteRunning = true; try { @@ -466,9 +514,20 @@ class _MainAppState extends State with WidgetsBindingObserver { } } - final synced = await downloadProvider.executeSyncRules(_serverManager); - if (synced.isNotEmpty) { - showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first)); + if (targetKeys != null) { + for (final key in targetKeys) { + if (!downloadProvider.hasSyncRule(key)) continue; + final result = await downloadProvider.executeSyncRuleFor(key, _serverManager); + if (result != null && result.queuedCount > 0) { + final title = result.title ?? 'Unknown'; + showMainSnackBar(t.downloads.syncedNewEpisodes(count: '1', title: '$title (${result.queuedCount})')); + } + } + } else { + final synced = await downloadProvider.executeSyncRules(_serverManager, force: force); + if (synced.isNotEmpty) { + showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first)); + } } } finally { _isAutoDeleteRunning = false; @@ -543,22 +602,35 @@ class _MainAppState extends State with WidgetsBindingObserver { final offlineModeProvider = context.read(); final downloadProvider = context.read(); + // Offline-sync drain replays a batch of queued watch actions without + // per-item data, so we can't target rules — force a full pass. _offlineWatchSyncService.onWatchStatesRefreshed = () async { - await _autoDeleteAndSync(downloadProvider); + await _autoDeleteAndSync(downloadProvider, force: true); }; - // Also trigger sync rules when watch state changes during a session. - // Debounced to batch rapid changes (binge watching, bulk mark-watched). + // In-session watch events carry the episode's parent chain, so we + // only re-evaluate rules that actually cover the watched item — + // leaves unrelated collection/playlist rules alone. Debounced so + // binge-watching coalesces into one pass. _watchStateSubscription = WatchStateNotifier().stream.listen((event) { if (event.changeType != WatchStateChangeType.watched) return; if (VideoPlayerScreenState.activeRatingKey == event.ratingKey) return; + _pendingSyncKeys.add(event.globalKey); + for (final parentKey in event.parentChain) { + _pendingSyncKeys.add(buildGlobalKey(event.serverId, parentKey)); + } + _syncDebounce?.cancel(); _syncDebounce = Timer(const Duration(seconds: 5), () { - _autoDeleteAndSync(downloadProvider); + final keys = _pendingSyncKeys.toList(); + _pendingSyncKeys.clear(); + _autoDeleteAndSync(downloadProvider, targetKeys: keys); }); }); + _startConnectivitySyncTrigger(downloadProvider); + _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider); return _offlineWatchSyncService; }, diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index bc82eca7..c9829f07 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -615,26 +615,60 @@ class DownloadProvider extends ChangeNotifier { } } - /// Queue all video items from a playlist for download. - /// Returns the number of items queued. - Future queuePlaylistDownload( + /// Queue every playable item from a collection/playlist for download. + /// + /// Movies and episodes are queued directly. Shows and seasons are expanded + /// into their episodes (when [expandShows] is true). Music items, nested + /// collections/playlists, and unknown types are skipped. + Future queueListDownload( List items, PlexClient client, { DownloadFilter filter = DownloadFilter.all, + bool expandShows = true, }) async { if (await DownloadManagerService.shouldBlockDownloadOnCellular()) { throw CellularDownloadBlockedException(); } + final unwatchedOnly = filter == DownloadFilter.unwatched; int count = 0; - for (final item in items) { - final mt = item.mediaType; - if (mt != PlexMediaType.movie && mt != PlexMediaType.episode) continue; - if (filter == DownloadFilter.unwatched && item.isWatched && !item.hasActiveProgress) continue; + Future queueItem(PlexMetadata item) async { + if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return; final queued = await _queueSingleDownload(item, client); if (queued) count++; } + + Future expandSeason(String seasonRatingKey) async { + final episodes = await client.getChildren(seasonRatingKey); + for (final ep in episodes) { + if (ep.type != ContentTypes.episode) continue; + await queueItem(ep); + } + } + + for (final item in items) { + final mt = item.mediaType; + switch (mt) { + case PlexMediaType.movie: + case PlexMediaType.episode: + await queueItem(item); + case PlexMediaType.show: + if (!expandShows) break; + final seasons = await client.getChildren(item.ratingKey); + for (final season in seasons) { + if (season.type == ContentTypes.season) { + await expandSeason(season.ratingKey); + } + } + case PlexMediaType.season: + if (!expandShows) break; + await expandSeason(item.ratingKey); + default: + // Skip music, clips, nested collections/playlists, unknown types. + break; + } + } return count; } @@ -1063,13 +1097,20 @@ class DownloadProvider extends ChangeNotifier { /// Get a sync rule for the given item SyncRuleItem? getSyncRule(String globalKey) => _syncRules[globalKey]; - /// Create a sync rule for a show or season. + /// 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 + /// the Sync Rules screen shows the item's title immediately instead of a bare + /// rating key — useful for collection/playlist rules where no underlying + /// episode download would otherwise populate it. Future createSyncRule({ required String serverId, required String ratingKey, required String targetType, required int episodeCount, int mediaIndex = 0, + String downloadFilter = SyncRuleFilter.unwatched, + PlexMetadata? targetMetadata, }) async { final globalKey = buildGlobalKey(serverId, ratingKey); await _database.insertSyncRule( @@ -1079,18 +1120,24 @@ class DownloadProvider extends ChangeNotifier { targetType: targetType, episodeCount: episodeCount, mediaIndex: mediaIndex, + downloadFilter: downloadFilter, ); + if (targetMetadata != null) { + final withServer = targetMetadata.serverId != null ? targetMetadata : targetMetadata.copyWith(serverId: serverId); + _metadata[globalKey] = withServer; + } + // Reload to get the full row with id/timestamps final rule = await _database.getSyncRule(globalKey); if (rule != null) { _syncRules[globalKey] = rule; notifyListeners(); } - appLogger.i('Created sync rule: $globalKey ($targetType, keep $episodeCount)'); + appLogger.i('Created sync rule: $globalKey ($targetType, filter=$downloadFilter, keep $episodeCount)'); } - /// Update the episode count for an existing sync rule. + /// Update the episode count for an existing show/season sync rule. Future updateSyncRuleCount(String globalKey, int episodeCount) async { await _database.updateSyncRuleCount(globalKey, episodeCount); final existing = _syncRules[globalKey]; @@ -1101,6 +1148,17 @@ class DownloadProvider extends ChangeNotifier { appLogger.i('Updated sync rule $globalKey: keep $episodeCount'); } + /// Update the download filter for an existing collection/playlist sync rule. + Future updateSyncRuleFilter(String globalKey, String downloadFilter) async { + await _database.updateSyncRuleFilter(globalKey, downloadFilter); + final existing = _syncRules[globalKey]; + if (existing != null) { + _syncRules[globalKey] = existing.copyWith(downloadFilter: downloadFilter); + notifyListeners(); + } + appLogger.i('Updated sync rule $globalKey: filter=$downloadFilter'); + } + /// Toggle a sync rule's enabled state. Future setSyncRuleEnabled(String globalKey, bool enabled) async { await _database.updateSyncRuleEnabled(globalKey, enabled); @@ -1122,8 +1180,12 @@ class DownloadProvider extends ChangeNotifier { /// Execute all sync rules: auto-delete watched + queue replacements. /// + /// Pass [force] `true` from user-initiated triggers (watch-state events, + /// offline-sync drains) to bypass the executor's cooldown. Defaults to + /// `false` for background probes (e.g. connectivity reconnects). + /// /// Returns titles of newly queued items (for snackbar display). - Future> executeSyncRules(MultiServerManager serverManager) async { + Future> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async { if (_syncRules.isEmpty) return []; final results = await _syncRuleExecutor.executeSyncRules( @@ -1132,6 +1194,7 @@ class DownloadProvider extends ChangeNotifier { metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSingleDownload(episode, client, mediaIndex: mediaIndex), + force: force, ); return results.where((r) => r.queuedCount > 0).map((r) { @@ -1140,6 +1203,21 @@ class DownloadProvider extends ChangeNotifier { }).toList(); } + /// Execute a single sync rule immediately (eager path for `addToPlaylist` / + /// `addToCollection`). Bypasses the cooldown. + Future executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async { + if (!_syncRules.containsKey(globalKey)) return null; + + return _syncRuleExecutor.executeSingleRule( + globalKey: globalKey, + serverManager: serverManager, + downloads: Map.unmodifiable(_downloads), + metadata: Map.unmodifiable(_metadata), + queueSingleDownload: (episode, client, {int mediaIndex = 0}) => + _queueSingleDownload(episode, client, mediaIndex: mediaIndex), + ); + } + Future _loadSyncRules() async { try { _syncRules.clear(); diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index ddca59e9..524e8032 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../models/plex_metadata.dart'; +import '../providers/download_provider.dart'; +import '../utils/download_utils.dart'; import '../widgets/desktop_app_bar.dart'; import '../i18n/strings.g.dart'; import '../utils/dialogs.dart'; @@ -68,11 +71,27 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen getAppBarActions() { + // Select the specific bool we care about so unrelated DownloadProvider + // ticks (e.g. active download progress) don't rebuild the app bar. + final hasRule = context.select((p) => p.hasSyncRule(widget.collection.globalKey)); + return [ if (items.isNotEmpty) ...[ FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), ], + FocusableAction( + icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, + tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, + onPressed: hasRule ? _manageCollectionSyncRule : _downloadCollection, + iconColor: hasRule ? Colors.teal : null, + ), + if (hasRule) + FocusableAction( + icon: Symbols.sync_disabled_rounded, + tooltip: t.downloads.removeSyncRule, + onPressed: _removeCollectionSyncRule, + ), FocusableAction( icon: Symbols.delete_rounded, tooltip: t.common.delete, @@ -82,6 +101,44 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _downloadCollection() async { + if (items.isEmpty) { + showErrorSnackBar(context, t.collections.empty); + return; + } + + final downloadProvider = context.read(); + try { + final result = await showCollectionDownloadOptionsAndQueue( + context, + collectionMetadata: widget.collection, + items: items, + client: client, + downloadProvider: downloadProvider, + ); + if (result == null || !mounted) return; + showSuccessSnackBar(context, result.toSnackBarMessage()); + } catch (e) { + appLogger.e('Failed to queue collection download', error: e); + if (mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + } + } + + Future _manageCollectionSyncRule() => manageSyncRule( + context, + downloadProvider: context.read(), + globalKey: widget.collection.globalKey, + ); + + Future _removeCollectionSyncRule() => removeSyncRuleAndSnack( + context, + downloadProvider: context.read(), + globalKey: widget.collection.globalKey, + displayTitle: widget.collection.displayTitle, + ); + Future _deleteCollection() async { int? sectionId = widget.collection.librarySectionID; if (sectionId == null && items.isNotEmpty) { diff --git a/lib/screens/downloads/sync_rules_screen.dart b/lib/screens/downloads/sync_rules_screen.dart index 12f0af0e..b8499f48 100644 --- a/lib/screens/downloads/sync_rules_screen.dart +++ b/lib/screens/downloads/sync_rules_screen.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import '../../database/app_database.dart'; import '../../models/plex_metadata.dart'; import '../../providers/download_provider.dart'; +import '../../services/sync_rule_executor.dart'; +import '../../utils/content_utils.dart'; import '../../utils/download_utils.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focusable_list_tile.dart'; @@ -59,6 +61,48 @@ class _SyncRuleTile extends StatelessWidget { this.autofocus = false, }); + IconData _leadingIcon() { + switch (rule.targetType) { + case ContentTypes.playlist: + return Symbols.playlist_play_rounded; + case ContentTypes.collection: + return Symbols.collections_bookmark_rounded; + case ContentTypes.show: + case ContentTypes.season: + return Symbols.tv_rounded; + default: + return Symbols.sync_rounded; + } + } + + String _subtitle() { + switch (rule.targetType) { + case ContentTypes.collection: + case ContentTypes.playlist: + return rule.downloadFilter == SyncRuleFilter.all ? t.downloads.syncAllItems : t.downloads.syncUnwatchedItems; + default: + return t.downloads.keepNUnwatched(count: rule.episodeCount.toString()); + } + } + + Future _onTap(BuildContext context) async { + if (rule.isListRule) { + await editSyncRuleFilter( + context, + downloadProvider: downloadProvider, + globalKey: rule.globalKey, + currentFilter: rule.downloadFilter, + ); + } else { + await editSyncRuleCount( + context, + downloadProvider: downloadProvider, + globalKey: rule.globalKey, + currentCount: rule.episodeCount, + ); + } + } + @override Widget build(BuildContext context) { final meta = metadata[rule.globalKey]; @@ -66,19 +110,14 @@ class _SyncRuleTile extends StatelessWidget { return FocusableListTile( autofocus: autofocus, - leading: Icon(Symbols.sync_rounded, color: rule.enabled ? Colors.teal : null, size: 20), + leading: Icon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(t.downloads.keepNUnwatched(count: rule.episodeCount.toString())), + subtitle: Text(_subtitle()), trailing: Switch( value: rule.enabled, onChanged: (value) => downloadProvider.setSyncRuleEnabled(rule.globalKey, value), ), - onTap: () => editSyncRuleCount( - context, - downloadProvider: downloadProvider, - globalKey: rule.globalKey, - currentCount: rule.episodeCount, - ), + onTap: () => _onTap(context), ); } } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 7d409fd4..ed7ef66b 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -17,8 +17,10 @@ import 'package:provider/provider.dart'; import 'playlist_item_card.dart'; import '../../i18n/strings.g.dart'; import '../../providers/download_provider.dart'; +import '../../utils/content_utils.dart'; import '../../utils/dialogs.dart'; import '../../utils/download_utils.dart'; +import '../../utils/global_key_utils.dart'; import '../../utils/snackbar_helper.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -56,13 +58,30 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen getAppBarActions() { + final isVideoPlaylist = widget.playlist.playlistType == 'video'; + final globalKey = _playlistGlobalKey(); + // Select the specific bool we care about so unrelated DownloadProvider + // ticks (e.g. active download progress) don't rebuild the app bar. + final hasRule = isVideoPlaylist && context.select((p) => p.hasSyncRule(globalKey)); + return [ if (items.isNotEmpty) ...[ FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), ], - if (items.isNotEmpty && widget.playlist.playlistType == 'video') - FocusableAction(icon: Symbols.download_rounded, tooltip: t.downloads.downloadNow, onPressed: _downloadPlaylist), + if (isVideoPlaylist && (items.isNotEmpty || hasRule)) + FocusableAction( + icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, + tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, + onPressed: hasRule ? _managePlaylistSyncRule : _downloadPlaylist, + iconColor: hasRule ? Colors.teal : null, + ), + if (hasRule) + FocusableAction( + icon: Symbols.sync_disabled_rounded, + tooltip: t.downloads.removeSyncRule, + onPressed: _removePlaylistSyncRule, + ), if (!widget.playlist.smart) FocusableAction( icon: Symbols.delete_rounded, @@ -73,6 +92,27 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen PlexMetadata( + ratingKey: widget.playlist.ratingKey, + type: ContentTypes.playlist, + title: widget.playlist.title, + thumb: widget.playlist.thumb, + serverId: widget.playlist.serverId ?? client.serverId, + serverName: widget.playlist.serverName, + ); + + String _playlistGlobalKey() => buildGlobalKey(widget.playlist.serverId ?? client.serverId, widget.playlist.ratingKey); + + Future _managePlaylistSyncRule() => + manageSyncRule(context, downloadProvider: context.read(), globalKey: _playlistGlobalKey()); + + Future _removePlaylistSyncRule() => removeSyncRuleAndSnack( + context, + downloadProvider: context.read(), + globalKey: _playlistGlobalKey(), + displayTitle: widget.playlist.title, + ); + // Focus management for regular (non-smart) reorderable lists final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list'); @@ -153,16 +193,16 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen(context, listen: false); try { - final count = await showPlaylistDownloadOptionsAndQueue( + final result = await showPlaylistDownloadOptionsAndQueue( context, + playlistMetadata: _playlistAsMetadata(), items: items, client: client, downloadProvider: downloadProvider, ); - if (count == null || !mounted) return; + if (result == null || !mounted) return; - final message = count > 1 ? t.downloads.itemsQueued(count: count) : t.downloads.downloadQueued; - showSuccessSnackBar(context, message); + showSuccessSnackBar(context, result.toSnackBarMessage()); } on CellularDownloadBlockedException { if (mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 6e8581bf..8681e932 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -106,9 +106,6 @@ class DownloadManagerService { /// Public method to check if downloads should be blocked due to cellular-only setting /// Can be used by DownloadProvider to show user-friendly error static Future shouldBlockDownloadOnCellular() async { - final settings = await SettingsService.getInstance(); - if (!settings.getDownloadOnWifiOnly()) return false; - final List connectivity; try { connectivity = await Connectivity().checkConnectivity(); @@ -116,7 +113,16 @@ class DownloadManagerService { // connectivity_plus can throw PlatformException on Windows — don't block return false; } - // Block if on cellular and NOT on WiFi (allow if both are available) + return shouldBlockDownloadOnCellularWith(connectivity); + } + + /// Same check as [shouldBlockDownloadOnCellular] but uses a pre-read + /// connectivity result so callers that already queried connectivity don't + /// pay for a second platform round-trip. + static Future shouldBlockDownloadOnCellularWith(List connectivity) async { + final settings = await SettingsService.getInstance(); + if (!settings.getDownloadOnWifiOnly()) return false; + if (connectivity.isEmpty) return false; return connectivity.contains(ConnectivityResult.mobile) && !connectivity.contains(ConnectivityResult.wifi) && !connectivity.contains(ConnectivityResult.ethernet); diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index f483a730..6818ab76 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -1,3 +1,5 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; + import '../database/app_database.dart'; import '../models/download_models.dart'; import '../models/plex_metadata.dart'; @@ -8,6 +10,13 @@ import 'download_manager_service.dart'; import 'multi_server_manager.dart'; import 'plex_client.dart'; +/// Sync-rule filter values stored in `SyncRules.downloadFilter`. +class SyncRuleFilter { + SyncRuleFilter._(); + static const String all = 'all'; + static const String unwatched = 'unwatched'; +} + /// Result of executing a single sync rule. class SyncRuleResult { final String globalKey; @@ -17,41 +26,67 @@ class SyncRuleResult { const SyncRuleResult({required this.globalKey, this.title, required this.queuedCount}); } -/// Evaluates sync rules and queues downloads to maintain the target episode count. +/// Evaluates sync rules and queues downloads so the device matches the rule's target. /// -/// Each sync rule says "keep N unwatched episodes downloaded for show/season X". -/// The executor counts how many unwatched episodes are already downloaded, -/// calculates the deficit, and queues new episodes to fill the gap. +/// Rule types: +/// - **show** / **season**: keep N unwatched episodes queued (0 = all unwatched). +/// - **collection** / **playlist**: mirror the list's current contents, expanding +/// shows/seasons into episodes, filtered by `downloadFilter` (`all` or `unwatched`). class SyncRuleExecutor { final AppDatabase _database; bool _isExecuting = false; + DateTime? _lastFullRunAt; + + static const Duration _cooldownWifi = Duration(minutes: 30); + static const Duration _cooldownCellular = Duration(hours: 3); SyncRuleExecutor({required AppDatabase database}) : _database = database; bool get isExecuting => _isExecuting; - /// Execute all sync rules and return results for newly queued items. + /// Execute every enabled sync rule. /// - /// [downloads] is the current download state map from DownloadProvider. - /// [metadata] is the current metadata map from DownloadProvider. - /// [queueSingleDownload] is a callback to queue a single episode via DownloadProvider. + /// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only + /// applies to background probes — reasons the rule set may have drifted + /// without the app knowing, i.e. connectivity transitions. User-initiated + /// runs (a watch event flushing, a sync-queue drain) pass [force] `true` + /// 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). Future> executeSyncRules({ required MultiServerManager serverManager, required Map downloads, required Map metadata, required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + bool force = false, }) async { if (_isExecuting) { appLogger.d('Sync rule execution already in progress, skipping'); return []; } - // Respect WiFi-only setting - if (await DownloadManagerService.shouldBlockDownloadOnCellular()) { + // Read connectivity once for both the WiFi-only gate and the cooldown pick. + final List connectivity = await _readConnectivity(); + if (await DownloadManagerService.shouldBlockDownloadOnCellularWith(connectivity)) { appLogger.d('Skipping sync rules — cellular download blocked'); return []; } + if (!force && _lastFullRunAt != null) { + final hasWifi = + connectivity.contains(ConnectivityResult.wifi) || connectivity.contains(ConnectivityResult.ethernet); + final cooldown = hasWifi ? _cooldownWifi : _cooldownCellular; + final elapsed = DateTime.now().difference(_lastFullRunAt!); + if (elapsed < cooldown) { + appLogger.d( + 'Sync rules cooldown active (${elapsed.inMinutes}m < ${cooldown.inMinutes}m, hasWifi=$hasWifi) — skipping', + ); + return []; + } + } + _isExecuting = true; try { final rules = await _database.getSyncRules(); @@ -78,12 +113,54 @@ class SyncRuleExecutor { } } + _lastFullRunAt = DateTime.now(); return results; } finally { _isExecuting = false; } } + /// Execute one rule by global key. Used for the eager trigger after + /// `addToPlaylist` / `addToCollection`. Not throttled by the cooldown. + Future executeSingleRule({ + required String globalKey, + required MultiServerManager serverManager, + required Map downloads, + required Map metadata, + required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + }) async { + if (_isExecuting) { + appLogger.d('Sync rule execution already in progress, skipping single-rule run for $globalKey'); + return null; + } + + if (await DownloadManagerService.shouldBlockDownloadOnCellular()) { + appLogger.d('Skipping single sync rule $globalKey — cellular download blocked'); + return null; + } + + final rule = await _database.getSyncRule(globalKey); + if (rule == null || !rule.enabled) { + return null; + } + + _isExecuting = true; + try { + return await _executeRule( + rule: rule, + serverManager: serverManager, + downloads: downloads, + metadata: metadata, + queueSingleDownload: queueSingleDownload, + ); + } catch (e) { + appLogger.w('Failed to execute single sync rule $globalKey: $e'); + return null; + } finally { + _isExecuting = false; + } + } + Future _executeRule({ required SyncRuleItem rule, required MultiServerManager serverManager, @@ -97,12 +174,45 @@ class SyncRuleExecutor { return null; } - // Collect all unwatched episodes from server + switch (rule.targetType) { + case ContentTypes.show: + case ContentTypes.season: + return _executeEpisodeRule( + rule: rule, + client: client, + downloads: downloads, + metadata: metadata, + queueSingleDownload: queueSingleDownload, + ); + case ContentTypes.collection: + case ContentTypes.playlist: + return _executeListRule( + rule: rule, + client: client, + downloads: downloads, + metadata: metadata, + queueSingleDownload: queueSingleDownload, + ); + default: + appLogger.w('Sync rule ${rule.globalKey}: unknown targetType ${rule.targetType}'); + return null; + } + } + + /// Keep [rule.episodeCount] unwatched episodes queued for a show/season + /// (0 = all). Always "unwatched" — watched/all filtering doesn't apply here. + Future _executeEpisodeRule({ + required SyncRuleItem rule, + required PlexClient client, + required Map downloads, + required Map metadata, + required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + }) async { final unwatchedEpisodes = []; if (rule.targetType == ContentTypes.show) { - await _collectUnwatchedForShow(client, rule.serverId, rule.ratingKey, unwatchedEpisodes); + await _collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes); } else { - await _collectUnwatchedForSeason(client, rule.serverId, rule.ratingKey, unwatchedEpisodes); + await _collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes); } if (unwatchedEpisodes.isEmpty) { @@ -143,44 +253,136 @@ class SyncRuleExecutor { await _database.updateSyncRuleLastExecuted(rule.globalKey); - // Get display title from metadata final displayTitle = metadata[rule.globalKey]?.title; appLogger.i('Sync rule ${rule.globalKey}: queued $queued episodes (had $alreadyHave/$targetCount)'); return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued); } + /// Collection/playlist logic: fetch the list, expand any shows/seasons into + /// episodes, filter by [rule.downloadFilter], queue everything not already + /// downloaded. No deficit cap. `mediaIndex` is always 0 for these rules. + Future _executeListRule({ + required SyncRuleItem rule, + required PlexClient client, + required Map downloads, + required Map metadata, + required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + }) async { + final List rootItems; + try { + rootItems = rule.targetType == ContentTypes.collection + ? await client.getCollectionItems(rule.ratingKey) + : await client.getPlaylist(rule.ratingKey); + } 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; + } + + final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched; + final candidates = []; + await _collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: candidates); + + if (candidates.isEmpty) { + appLogger.d('Sync rule ${rule.globalKey}: no candidates after filtering'); + await _database.updateSyncRuleLastExecuted(rule.globalKey); + return null; + } + + int queued = 0; + for (final item in candidates) { + final gk = buildGlobalKey(rule.serverId, item.ratingKey); + if (_isActiveDownload(downloads[gk])) continue; + + final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId); + final ok = await queueSingleDownload(itemWithServer, client, mediaIndex: 0); + if (ok) { + queued++; + appLogger.d('Sync rule ${rule.globalKey}: queued ${item.title}'); + } + } + + await _database.updateSyncRuleLastExecuted(rule.globalKey); + + final displayTitle = metadata[rule.globalKey]?.title; + appLogger.i('Sync rule ${rule.globalKey}: queued $queued items from ${candidates.length} candidates'); + + return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued); + } + + /// Walks [items] and collects playable movie/episode entries into [out]. + /// Shows and seasons are expanded into their episodes; music and nested + /// collections/playlists are skipped. + Future _collectItemsForList( + PlexClient client, + List items, { + required bool unwatchedOnly, + required List out, + }) async { + for (final item in items) { + final type = item.type?.toLowerCase(); + switch (type) { + case ContentTypes.movie: + case ContentTypes.episode: + if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break; + out.add(item); + case ContentTypes.show: + await _collectEpisodesForShow(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out); + case ContentTypes.season: + await _collectEpisodesForSeason(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out); + default: + // Skip music, clips, nested collections/playlists, unknown types. + break; + } + } + } + + Future _collectEpisodesForShow( + PlexClient client, + String showRatingKey, { + required bool unwatchedOnly, + required List out, + }) async { + final seasons = await client.getChildren(showRatingKey); + for (final season in seasons) { + if (season.type == ContentTypes.season) { + await _collectEpisodesForSeason(client, season.ratingKey, unwatchedOnly: unwatchedOnly, out: out); + } + } + } + + Future _collectEpisodesForSeason( + PlexClient client, + String seasonRatingKey, { + required bool unwatchedOnly, + required List out, + }) async { + final episodes = await client.getChildren(seasonRatingKey); + for (final ep in episodes) { + if (ep.type != ContentTypes.episode) continue; + if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue; + out.add(ep); + } + } + static bool _isActiveDownload(DownloadProgress? p) => p != null && (p.status == DownloadStatus.completed || p.status == DownloadStatus.downloading || p.status == DownloadStatus.queued); - Future _collectUnwatchedForShow( - PlexClient client, - String serverId, - String showRatingKey, - List out, - ) async { - final seasons = await client.getChildren(showRatingKey); - for (final season in seasons) { - if (season.type == ContentTypes.season) { - await _collectUnwatchedForSeason(client, serverId, season.ratingKey, out); - } - } - } - - Future _collectUnwatchedForSeason( - PlexClient client, - String serverId, - String seasonRatingKey, - List out, - ) async { - final episodes = await client.getChildren(seasonRatingKey); - for (final ep in episodes) { - if (ep.type == 'episode' && !ep.isWatched && !ep.hasActiveProgress) { - out.add(ep); - } + Future> _readConnectivity() async { + try { + return await Connectivity().checkConnectivity(); + } catch (_) { + // connectivity_plus can throw PlatformException on Windows — treat as unknown. + return const []; } } } diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index b182cae8..78416bd0 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -3,12 +3,15 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; import '../models/plex_metadata.dart'; +import '../database/app_database.dart'; import '../providers/download_provider.dart'; import '../services/plex_client.dart'; +import '../services/sync_rule_executor.dart'; import 'content_utils.dart'; import 'dialogs.dart'; import 'download_version_utils.dart'; import 'global_key_utils.dart'; +import 'snackbar_helper.dart'; /// Dialog option for the download picker. Typed to avoid stringly-typed values. enum _DownloadChoice { all, unwatched, next5, next10, custom } @@ -21,11 +24,23 @@ class DownloadResult { final int count; final bool syncRuleCreated; final bool syncRuleUpdated; - const DownloadResult({required this.count, this.syncRuleCreated = false, this.syncRuleUpdated = false}); + + /// `true` when the rule targets a collection/playlist — affects the + /// "created" snackbar wording (no "unwatched episodes" suffix). + final bool isListRule; + + const DownloadResult({ + required this.count, + this.syncRuleCreated = false, + this.syncRuleUpdated = false, + this.isListRule = false, + }); String toSnackBarMessage() { if (syncRuleUpdated) return t.downloads.syncRuleUpdated; - if (syncRuleCreated) return t.downloads.syncRuleCreated(count: count.toString()); + if (syncRuleCreated) { + return isListRule ? t.downloads.syncRuleListCreated : t.downloads.syncRuleCreated(count: count.toString()); + } if (count > 1) return t.downloads.episodesQueued(count: count); return t.downloads.downloadQueued; } @@ -138,15 +153,24 @@ Future showDownloadOptionsAndQueue( ); } -/// Shows download options dialog for playlists, then queues the download. -/// Returns the number of items queued, or null if cancelled. -Future showPlaylistDownloadOptionsAndQueue( +/// Shows download options dialog for a collection or playlist, then queues +/// the download. Offers both one-time download and "Keep Synced" (creates or +/// updates a sync rule for the target). +/// +/// [rootMetadata] is the collection or playlist itself — used to persist the +/// title/thumb for the sync rule and build the rule's global key. +/// [targetType] must be [ContentTypes.collection] or [ContentTypes.playlist]. +Future showListDownloadOptionsAndQueue( BuildContext context, { + required PlexMetadata rootMetadata, + required String targetType, required List items, required PlexClient client, required DownloadProvider downloadProvider, }) async { - final selected = await showOptionPickerDialog( + assert(targetType == ContentTypes.collection || targetType == ContentTypes.playlist); + + final selectedFilter = await showOptionPickerDialog( context, title: t.downloads.downloadNow, options: [ @@ -155,11 +179,85 @@ Future showPlaylistDownloadOptionsAndQueue( ], ); - if (selected == null || !context.mounted) return null; + if (selectedFilter == null || !context.mounted) return null; - return await downloadProvider.queuePlaylistDownload(items, client, filter: selected); + final syncChoice = await showOptionPickerDialog<_SyncChoice>( + context, + title: t.downloads.downloadNow, + options: [ + (icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce), + (icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced), + ], + ); + if (syncChoice == null || !context.mounted) return null; + + final serverId = rootMetadata.serverId ?? client.serverId; + final globalKey = buildGlobalKey(serverId, rootMetadata.ratingKey); + final filterString = selectedFilter == DownloadFilter.unwatched ? SyncRuleFilter.unwatched : SyncRuleFilter.all; + + bool syncRuleCreated = false; + bool syncRuleUpdated = false; + + if (syncChoice == _SyncChoice.keepSynced) { + if (downloadProvider.hasSyncRule(globalKey)) { + await downloadProvider.updateSyncRuleFilter(globalKey, filterString); + syncRuleUpdated = true; + } else { + await downloadProvider.createSyncRule( + serverId: serverId, + ratingKey: rootMetadata.ratingKey, + targetType: targetType, + episodeCount: 0, + mediaIndex: 0, + downloadFilter: filterString, + targetMetadata: rootMetadata, + ); + syncRuleCreated = true; + } + } + + final count = await downloadProvider.queueListDownload(items, client, filter: selectedFilter); + + return DownloadResult( + count: count, + syncRuleCreated: syncRuleCreated, + syncRuleUpdated: syncRuleUpdated, + isListRule: true, + ); } +/// Shows the shared list-download dialog for a playlist. +Future showPlaylistDownloadOptionsAndQueue( + BuildContext context, { + required PlexMetadata playlistMetadata, + required List items, + required PlexClient client, + required DownloadProvider downloadProvider, +}) => showListDownloadOptionsAndQueue( + context, + rootMetadata: playlistMetadata, + targetType: ContentTypes.playlist, + items: items, + client: client, + downloadProvider: downloadProvider, +); + +/// Shows the shared list-download dialog for a collection. +Future showCollectionDownloadOptionsAndQueue( + BuildContext context, { + required PlexMetadata collectionMetadata, + required List items, + required PlexClient client, + required DownloadProvider downloadProvider, +}) => showListDownloadOptionsAndQueue( + context, + rootMetadata: collectionMetadata, + targetType: ContentTypes.collection, + items: items, + client: client, + downloadProvider: downloadProvider, +); + Future _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async { final result = await showTextInputDialog( context, @@ -197,6 +295,28 @@ Future editSyncRuleCount( return true; } +/// Shows a dialog to edit a collection/playlist sync rule's filter. Returns +/// true if the filter changed. +Future editSyncRuleFilter( + BuildContext context, { + required DownloadProvider downloadProvider, + required String globalKey, + required String currentFilter, +}) async { + final selected = await showOptionPickerDialog( + context, + title: t.downloads.editSyncFilter, + options: [ + (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: SyncRuleFilter.all), + (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: SyncRuleFilter.unwatched), + ], + ); + if (selected == null || selected == currentFilter || !context.mounted) return false; + + await downloadProvider.updateSyncRuleFilter(globalKey, selected); + return true; +} + /// Shows a confirmation dialog to remove a sync rule. Returns true if removed. Future confirmAndRemoveSyncRule( BuildContext context, { @@ -215,3 +335,61 @@ Future confirmAndRemoveSyncRule( await downloadProvider.deleteSyncRule(globalKey); return true; } + +/// Whether this rule targets a collection or playlist (as opposed to a +/// show/season). Shared by detail screens, the sync rules screen, and the +/// context menu to dispatch between count vs. filter editing. +extension SyncRuleItemDispatch on SyncRuleItem { + bool get isListRule => targetType == ContentTypes.collection || targetType == ContentTypes.playlist; +} + +/// Open the right sync-rule edit dialog for [globalKey] and show a success +/// snackbar when anything changed. Used by both detail screens and the +/// context menu so they don't each reimplement the get-rule / edit / snack +/// dance. +Future manageSyncRule( + BuildContext context, { + required DownloadProvider downloadProvider, + required String globalKey, +}) async { + final rule = downloadProvider.getSyncRule(globalKey); + if (rule == null) return; + + final bool updated; + if (rule.isListRule) { + updated = await editSyncRuleFilter( + context, + downloadProvider: downloadProvider, + globalKey: globalKey, + currentFilter: rule.downloadFilter, + ); + } else { + updated = await editSyncRuleCount( + context, + downloadProvider: downloadProvider, + globalKey: globalKey, + currentCount: rule.episodeCount, + ); + } + if (updated && context.mounted) { + showSuccessSnackBar(context, t.downloads.syncRuleUpdated); + } +} + +/// Confirm + remove a sync rule and show a success snackbar. +Future removeSyncRuleAndSnack( + BuildContext context, { + required DownloadProvider downloadProvider, + required String globalKey, + required String displayTitle, +}) async { + final removed = await confirmAndRemoveSyncRule( + context, + downloadProvider: downloadProvider, + globalKey: globalKey, + displayTitle: displayTitle, + ); + if (removed && context.mounted) { + showSuccessSnackBar(context, t.downloads.syncRuleRemoved); + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a67263c6..c56ff1de 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; @@ -10,6 +11,7 @@ import '../models/plex_playlist.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; import '../utils/content_utils.dart'; +import '../utils/global_key_utils.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/offline_mode_provider.dart'; @@ -153,11 +155,27 @@ class MediaContextMenuState extends State { // Shuffle menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay)); - // Download (video playlists only) - if (isPlaylist && (widget.item as PlexPlaylist).playlistType == 'video') { - menuActions.add( - _MenuAction(value: 'download_playlist', icon: Symbols.download_rounded, label: t.downloads.downloadNow), - ); + // Download + sync-rule management. Video playlists and any collection + // qualify — collections can contain movies, episodes, and shows. + final isVideoPlaylist = isPlaylist && (widget.item as PlexPlaylist).playlistType == 'video'; + if (isVideoPlaylist || isCollection) { + final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemGlobalKey()); + if (hasRule) { + menuActions.add( + _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), + ); + menuActions.add( + _MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule), + ); + } else { + menuActions.add( + _MenuAction( + value: isPlaylist ? 'download_playlist' : 'download_collection', + icon: Symbols.download_rounded, + label: t.downloads.downloadNow, + ), + ); + } } // Delete @@ -552,6 +570,10 @@ class MediaContextMenuState extends State { await _handleDownloadPlaylist(context); break; + case 'download_collection': + await _handleDownloadCollection(context); + break; + case 'download': await _handleDownload(context); break; @@ -790,6 +812,7 @@ class MediaContextMenuState extends State { showSuccessSnackBar(context, t.playlists.itemAdded); // Trigger refresh of playlists tab LibraryRefreshNotifier().notifyPlaylistsChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, result); } else { appLogger.e('Failed to add item(s) to playlist $result - API returned false'); showErrorSnackBar(context, t.playlists.errorAdding); @@ -940,6 +963,7 @@ class MediaContextMenuState extends State { showSuccessSnackBar(context, t.collections.created); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); } else { appLogger.e('Failed to add item to new collection'); showErrorSnackBar(context, t.collections.errorAddingToCollection); @@ -962,6 +986,7 @@ class MediaContextMenuState extends State { showSuccessSnackBar(context, t.collections.addedToCollection); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, result); } else { appLogger.e('Failed to add item(s) to collection $result - API returned false'); showErrorSnackBar(context, t.collections.errorAddingToCollection); @@ -1135,6 +1160,39 @@ class MediaContextMenuState extends State { await ExternalPlayerService.launch(context: context, metadata: metadata, client: client); } + /// Handle download collection action — opens the same sync/one-time dialog + /// as playlists, wired to [showCollectionDownloadOptionsAndQueue]. + Future _handleDownloadCollection(BuildContext context) async { + final collection = widget.item as PlexMetadata; + final downloadProvider = Provider.of(context, listen: false); + final client = _getClientForItem(); + + try { + final items = await client.getCollectionItems(collection.ratingKey); + if (!context.mounted) return; + + final result = await showCollectionDownloadOptionsAndQueue( + context, + collectionMetadata: collection, + items: items, + client: client, + downloadProvider: downloadProvider, + ); + if (result == null || !context.mounted) return; + + showSuccessSnackBar(context, result.toSnackBarMessage()); + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } + } catch (e) { + appLogger.e('Failed to queue collection download', error: e); + if (context.mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + } + } + /// Handle download playlist action Future _handleDownloadPlaylist(BuildContext context) async { final playlist = widget.item as PlexPlaylist; @@ -1145,16 +1203,25 @@ class MediaContextMenuState extends State { final items = await client.getPlaylist(playlist.ratingKey); if (!context.mounted) return; - final count = await showPlaylistDownloadOptionsAndQueue( + final playlistMetadata = PlexMetadata( + ratingKey: playlist.ratingKey, + type: ContentTypes.playlist, + title: playlist.title, + thumb: playlist.thumb, + serverId: playlist.serverId ?? client.serverId, + serverName: playlist.serverName, + ); + + final result = await showPlaylistDownloadOptionsAndQueue( context, + playlistMetadata: playlistMetadata, items: items, client: client, downloadProvider: downloadProvider, ); - if (count == null || !context.mounted) return; + if (result == null || !context.mounted) return; - final message = count > 1 ? t.downloads.itemsQueued(count: count) : t.downloads.downloadQueued; - showSuccessSnackBar(context, message); + showSuccessSnackBar(context, result.toSnackBarMessage()); } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); @@ -1229,37 +1296,53 @@ class MediaContextMenuState extends State { } } - Future _handleManageSyncRule(BuildContext context) async { - final downloadProvider = Provider.of(context, listen: false); - final metadata = widget.item as PlexMetadata; - final syncRule = downloadProvider.getSyncRule(metadata.globalKey); - if (syncRule == null) return; + /// Resolve the sync-rule global key for whatever the menu item is — works + /// for both PlexMetadata (shows/seasons/collections/movies/episodes) and + /// PlexPlaylist. + String _itemGlobalKey() { + final item = widget.item; + if (item is PlexPlaylist) { + final serverId = item.serverId ?? _getClientForItem().serverId; + return buildGlobalKey(serverId, item.ratingKey); + } + return (item as PlexMetadata).globalKey; + } - final updated = await editSyncRuleCount( - context, - downloadProvider: downloadProvider, - globalKey: metadata.globalKey, - currentCount: syncRule.episodeCount, - ); - if (updated && context.mounted) { - showSuccessSnackBar(context, t.downloads.syncRuleUpdated); + String _itemDisplayTitle() { + final item = widget.item; + if (item is PlexPlaylist) return item.title; + return (item as PlexMetadata).displayTitle; + } + + Future _handleManageSyncRule(BuildContext context) => + manageSyncRule(context, downloadProvider: context.read(), globalKey: _itemGlobalKey()); + + /// Fire-and-forget: if a sync rule exists for the target list, run it now so + /// newly-added items download immediately instead of waiting for the next + /// cooldown-gated general pass. Fails silently — errors are logged only. + static void _triggerEagerSyncIfRuleExists(BuildContext context, String serverId, String listId) { + try { + final downloadProvider = Provider.of(context, listen: false); + final globalKey = buildGlobalKey(serverId, listId); + if (!downloadProvider.hasSyncRule(globalKey)) return; + final serverManager = Provider.of(context, listen: false).serverManager; + unawaited( + downloadProvider.executeSyncRuleFor(globalKey, serverManager).catchError((e) { + appLogger.w('Eager sync-rule run failed for $globalKey: $e'); + return null; + }), + ); + } catch (e) { + appLogger.w('Failed to schedule eager sync-rule run: $e'); } } - Future _handleRemoveSyncRule(BuildContext context) async { - final downloadProvider = Provider.of(context, listen: false); - final metadata = widget.item as PlexMetadata; - - final removed = await confirmAndRemoveSyncRule( - context, - downloadProvider: downloadProvider, - globalKey: metadata.globalKey, - displayTitle: metadata.displayTitle, - ); - if (removed && context.mounted) { - showSuccessSnackBar(context, t.downloads.syncRuleRemoved); - } - } + Future _handleRemoveSyncRule(BuildContext context) => removeSyncRuleAndSnack( + context, + downloadProvider: context.read(), + globalKey: _itemGlobalKey(), + displayTitle: _itemDisplayTitle(), + ); /// Handle delete media item action /// This permanently removes the media item and its associated files from the server