feat: collection/playlist sync rules

This commit is contained in:
edde746
2026-04-19 17:45:03 +02:00
parent 6115c21b44
commit a715f4e26b
43 changed files with 1178 additions and 146 deletions
+17 -1
View File
@@ -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<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
await (update(
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
}
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
await (update(
syncRules,
+75 -3
View File
@@ -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<String> downloadFilter = GeneratedColumn<String>(
'download_filter',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('unwatched'),
);
@override
List<GeneratedColumn> 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<SyncRuleItem> {
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<SyncRuleItem> {
required this.createdAt,
this.lastExecutedAt,
required this.mediaIndex,
required this.downloadFilter,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -2831,6 +2859,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
map['last_executed_at'] = Variable<int>(lastExecutedAt);
}
map['media_index'] = Variable<int>(mediaIndex);
map['download_filter'] = Variable<String>(downloadFilter);
return map;
}
@@ -2848,6 +2877,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
? const Value.absent()
: Value(lastExecutedAt),
mediaIndex: Value(mediaIndex),
downloadFilter: Value(downloadFilter),
);
}
@@ -2867,6 +2897,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
createdAt: serializer.fromJson<int>(json['createdAt']),
lastExecutedAt: serializer.fromJson<int?>(json['lastExecutedAt']),
mediaIndex: serializer.fromJson<int>(json['mediaIndex']),
downloadFilter: serializer.fromJson<String>(json['downloadFilter']),
);
}
@override
@@ -2883,6 +2914,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
'createdAt': serializer.toJson<int>(createdAt),
'lastExecutedAt': serializer.toJson<int?>(lastExecutedAt),
'mediaIndex': serializer.toJson<int>(mediaIndex),
'downloadFilter': serializer.toJson<String>(downloadFilter),
};
}
@@ -2897,6 +2929,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
int? createdAt,
Value<int?> 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<SyncRuleItem> {
? 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<SyncRuleItem> {
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<SyncRuleItem> {
..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<SyncRuleItem> {
createdAt,
lastExecutedAt,
mediaIndex,
downloadFilter,
);
@override
bool operator ==(Object other) =>
@@ -2977,7 +3016,8 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
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<SyncRuleItem> {
@@ -2991,6 +3031,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
final Value<int> createdAt;
final Value<int?> lastExecutedAt;
final Value<int> mediaIndex;
final Value<String> downloadFilter;
const SyncRulesCompanion({
this.id = const Value.absent(),
this.serverId = const Value.absent(),
@@ -3002,6 +3043,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
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<SyncRuleItem> {
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<SyncRuleItem> {
Expression<int>? createdAt,
Expression<int>? lastExecutedAt,
Expression<int>? mediaIndex,
Expression<String>? downloadFilter,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -3043,6 +3087,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
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<SyncRuleItem> {
Value<int>? createdAt,
Value<int?>? lastExecutedAt,
Value<int>? mediaIndex,
Value<String>? downloadFilter,
}) {
return SyncRulesCompanion(
id: id ?? this.id,
@@ -3069,6 +3115,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
createdAt: createdAt ?? this.createdAt,
lastExecutedAt: lastExecutedAt ?? this.lastExecutedAt,
mediaIndex: mediaIndex ?? this.mediaIndex,
downloadFilter: downloadFilter ?? this.downloadFilter,
);
}
@@ -3105,6 +3152,9 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
if (mediaIndex.present) {
map['media_index'] = Variable<int>(mediaIndex.value);
}
if (downloadFilter.present) {
map['download_filter'] = Variable<String>(downloadFilter.value);
}
return map;
}
@@ -3120,7 +3170,8 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
..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<int?> lastExecutedAt,
Value<int> mediaIndex,
Value<String> downloadFilter,
});
typedef $$SyncRulesTableUpdateCompanionBuilder =
SyncRulesCompanion Function({
@@ -4399,6 +4451,7 @@ typedef $$SyncRulesTableUpdateCompanionBuilder =
Value<int> createdAt,
Value<int?> lastExecutedAt,
Value<int> mediaIndex,
Value<String> downloadFilter,
});
class $$SyncRulesTableFilterComposer
@@ -4459,6 +4512,11 @@ class $$SyncRulesTableFilterComposer
column: $table.mediaIndex,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> 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<String> 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<String> get downloadFilter => $composableBuilder(
column: $table.downloadFilter,
builder: (column) => column,
);
}
class $$SyncRulesTableTableManager
@@ -4610,6 +4678,7 @@ class $$SyncRulesTableTableManager
Value<int> createdAt = const Value.absent(),
Value<int?> lastExecutedAt = const Value.absent(),
Value<int> mediaIndex = const Value.absent(),
Value<String> 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<int?> lastExecutedAt = const Value.absent(),
Value<int> mediaIndex = const Value.absent(),
Value<String> 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)))
+4 -3
View File
@@ -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.
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -764,7 +764,11 @@
"activeSyncRules": "同期ルール",
"noSyncRules": "同期ルールなし",
"manageSyncRule": "同期を管理",
"editEpisodeCount": "エピソード数"
"editEpisodeCount": "エピソード数",
"editSyncFilter": "同期フィルター",
"syncAllItems": "すべてのアイテムを同期中",
"syncUnwatchedItems": "未視聴のアイテムを同期中",
"syncRuleListCreated": "同期ルールを作成しました"
},
"shaders": {
"title": "シェーダー",
+5 -1
View File
@@ -764,7 +764,11 @@
"activeSyncRules": "동기화 규칙",
"noSyncRules": "동기화 규칙 없음",
"manageSyncRule": "동기화 관리",
"editEpisodeCount": "에피소드 수"
"editEpisodeCount": "에피소드 수",
"editSyncFilter": "동기화 필터",
"syncAllItems": "모든 항목 동기화 중",
"syncUnwatchedItems": "시청하지 않은 항목 동기화 중",
"syncRuleListCreated": "동기화 규칙이 생성되었습니다"
},
"shaders": {
"title": "셰이더",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -764,7 +764,11 @@
"activeSyncRules": "Правила синхронизации",
"noSyncRules": "Нет правил синхронизации",
"manageSyncRule": "Управление синхронизацией",
"editEpisodeCount": "Количество эпизодов"
"editEpisodeCount": "Количество эпизодов",
"editSyncFilter": "Фильтр синхронизации",
"syncAllItems": "Синхронизация всех элементов",
"syncUnwatchedItems": "Синхронизация непросмотренных элементов",
"syncRuleListCreated": "Правило синхронизации создано"
},
"shaders": {
"title": "Шейдеры",
+2 -2
View File
@@ -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
+8
View File
@@ -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',
+8
View File
@@ -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',
+16
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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画像スケーリング',
+8
View File
@@ -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 이미지 스케일링',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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 для более чёткого видео',
+8
View File
@@ -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',
+8
View File
@@ -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 图像缩放,使视频更清晰',
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -764,7 +764,11 @@
"activeSyncRules": "同步规则",
"noSyncRules": "没有同步规则",
"manageSyncRule": "管理同步",
"editEpisodeCount": "剧集数量"
"editEpisodeCount": "剧集数量",
"editSyncFilter": "同步筛选",
"syncAllItems": "同步所有项目",
"syncUnwatchedItems": "同步未观看项目",
"syncRuleListCreated": "同步规则已创建"
},
"shaders": {
"title": "着色器",
+82 -10
View File
@@ -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<MainApp> with WidgetsBindingObserver {
late final OfflineWatchSyncService _offlineWatchSyncService;
late final AppLifecycleListener _appLifecycleListener;
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Timer? _syncDebounce;
final Set<String> _pendingSyncKeys = <String>{};
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<MainApp> 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<MainApp> with WidgetsBindingObserver {
PaintingBinding.instance.imageCache.clearLiveImages();
}
/// Auto-delete watched downloads and execute sync rules.
/// Shared by onWatchStatesRefreshed and the WatchStateNotifier listener.
Future<void> _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<void> 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<ConnectivityResult> 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<void> _autoDeleteAndSync(
DownloadProvider downloadProvider, {
List<String>? targetKeys,
bool force = false,
}) async {
if (_isAutoDeleteRunning) return;
_isAutoDeleteRunning = true;
try {
@@ -466,9 +514,20 @@ class _MainAppState extends State<MainApp> 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<MainApp> with WidgetsBindingObserver {
final offlineModeProvider = context.read<OfflineModeProvider>();
final downloadProvider = context.read<DownloadProvider>();
// 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;
},
+89 -11
View File
@@ -615,26 +615,60 @@ class DownloadProvider extends ChangeNotifier {
}
}
/// Queue all video items from a playlist for download.
/// Returns the number of items queued.
Future<int> 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<int> queueListDownload(
List<PlexMetadata> 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<void> queueItem(PlexMetadata item) async {
if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return;
final queued = await _queueSingleDownload(item, client);
if (queued) count++;
}
Future<void> 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<void> 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<void> 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<void> 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<void> 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<List<String>> executeSyncRules(MultiServerManager serverManager) async {
Future<List<String>> 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<SyncRuleResult?> 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<void> _loadSyncRules() async {
try {
_syncRules.clear();
+57
View File
@@ -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<CollectionD
@override
List<FocusableAction> 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<DownloadProvider, bool>((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<CollectionD
];
}
Future<void> _downloadCollection() async {
if (items.isEmpty) {
showErrorSnackBar(context, t.collections.empty);
return;
}
final downloadProvider = context.read<DownloadProvider>();
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<void> _manageCollectionSyncRule() => manageSyncRule(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: widget.collection.globalKey,
);
Future<void> _removeCollectionSyncRule() => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: widget.collection.globalKey,
displayTitle: widget.collection.displayTitle,
);
Future<void> _deleteCollection() async {
int? sectionId = widget.collection.librarySectionID;
if (sectionId == null && items.isNotEmpty) {
+47 -8
View File
@@ -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<void> _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),
);
}
}
@@ -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<PlaylistDetai
@override
List<FocusableAction> 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<DownloadProvider, bool>((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<PlaylistDetai
];
}
PlexMetadata _playlistAsMetadata() => 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<void> _managePlaylistSyncRule() =>
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _playlistGlobalKey());
Future<void> _removePlaylistSyncRule() => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
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<PlaylistDetai
final downloadProvider = Provider.of<DownloadProvider>(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);
+10 -4
View File
@@ -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<bool> shouldBlockDownloadOnCellular() async {
final settings = await SettingsService.getInstance();
if (!settings.getDownloadOnWifiOnly()) return false;
final List<ConnectivityResult> 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<bool> shouldBlockDownloadOnCellularWith(List<ConnectivityResult> 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);
+241 -39
View File
@@ -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<List<SyncRuleResult>> executeSyncRules({
required MultiServerManager serverManager,
required Map<String, DownloadProgress> downloads,
required Map<String, PlexMetadata> metadata,
required Future<bool> 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<ConnectivityResult> 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<SyncRuleResult?> executeSingleRule({
required String globalKey,
required MultiServerManager serverManager,
required Map<String, DownloadProgress> downloads,
required Map<String, PlexMetadata> metadata,
required Future<bool> 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<SyncRuleResult?> _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<SyncRuleResult?> _executeEpisodeRule({
required SyncRuleItem rule,
required PlexClient client,
required Map<String, DownloadProgress> downloads,
required Map<String, PlexMetadata> metadata,
required Future<bool> Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload,
}) async {
final unwatchedEpisodes = <PlexMetadata>[];
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<SyncRuleResult?> _executeListRule({
required SyncRuleItem rule,
required PlexClient client,
required Map<String, DownloadProgress> downloads,
required Map<String, PlexMetadata> metadata,
required Future<bool> Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload,
}) async {
final List<PlexMetadata> 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 = <PlexMetadata>[];
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<void> _collectItemsForList(
PlexClient client,
List<PlexMetadata> items, {
required bool unwatchedOnly,
required List<PlexMetadata> 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<void> _collectEpisodesForShow(
PlexClient client,
String showRatingKey, {
required bool unwatchedOnly,
required List<PlexMetadata> 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<void> _collectEpisodesForSeason(
PlexClient client,
String seasonRatingKey, {
required bool unwatchedOnly,
required List<PlexMetadata> 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<void> _collectUnwatchedForShow(
PlexClient client,
String serverId,
String showRatingKey,
List<PlexMetadata> 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<void> _collectUnwatchedForSeason(
PlexClient client,
String serverId,
String seasonRatingKey,
List<PlexMetadata> 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<List<ConnectivityResult>> _readConnectivity() async {
try {
return await Connectivity().checkConnectivity();
} catch (_) {
// connectivity_plus can throw PlatformException on Windows — treat as unknown.
return const <ConnectivityResult>[];
}
}
}
+186 -8
View File
@@ -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<DownloadResult?> showDownloadOptionsAndQueue(
);
}
/// Shows download options dialog for playlists, then queues the download.
/// Returns the number of items queued, or null if cancelled.
Future<int?> 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<DownloadResult?> showListDownloadOptionsAndQueue(
BuildContext context, {
required PlexMetadata rootMetadata,
required String targetType,
required List<PlexMetadata> items,
required PlexClient client,
required DownloadProvider downloadProvider,
}) async {
final selected = await showOptionPickerDialog<DownloadFilter>(
assert(targetType == ContentTypes.collection || targetType == ContentTypes.playlist);
final selectedFilter = await showOptionPickerDialog<DownloadFilter>(
context,
title: t.downloads.downloadNow,
options: [
@@ -155,11 +179,85 @@ Future<int?> 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<DownloadResult?> showPlaylistDownloadOptionsAndQueue(
BuildContext context, {
required PlexMetadata playlistMetadata,
required List<PlexMetadata> 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<DownloadResult?> showCollectionDownloadOptionsAndQueue(
BuildContext context, {
required PlexMetadata collectionMetadata,
required List<PlexMetadata> items,
required PlexClient client,
required DownloadProvider downloadProvider,
}) => showListDownloadOptionsAndQueue(
context,
rootMetadata: collectionMetadata,
targetType: ContentTypes.collection,
items: items,
client: client,
downloadProvider: downloadProvider,
);
Future<int?> _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async {
final result = await showTextInputDialog(
context,
@@ -197,6 +295,28 @@ Future<bool> editSyncRuleCount(
return true;
}
/// Shows a dialog to edit a collection/playlist sync rule's filter. Returns
/// true if the filter changed.
Future<bool> editSyncRuleFilter(
BuildContext context, {
required DownloadProvider downloadProvider,
required String globalKey,
required String currentFilter,
}) async {
final selected = await showOptionPickerDialog<String>(
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<bool> confirmAndRemoveSyncRule(
BuildContext context, {
@@ -215,3 +335,61 @@ Future<bool> 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<void> 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<void> 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);
}
}
+119 -36
View File
@@ -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<MediaContextMenu> {
// 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<DownloadProvider>(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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
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<void> _handleDownloadCollection(BuildContext context) async {
final collection = widget.item as PlexMetadata;
final downloadProvider = Provider.of<DownloadProvider>(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<void> _handleDownloadPlaylist(BuildContext context) async {
final playlist = widget.item as PlexPlaylist;
@@ -1145,16 +1203,25 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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<MediaContextMenu> {
}
}
Future<void> _handleManageSyncRule(BuildContext context) async {
final downloadProvider = Provider.of<DownloadProvider>(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<void> _handleManageSyncRule(BuildContext context) =>
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), 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<DownloadProvider>(context, listen: false);
final globalKey = buildGlobalKey(serverId, listId);
if (!downloadProvider.hasSyncRule(globalKey)) return;
final serverManager = Provider.of<MultiServerProvider>(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<void> _handleRemoveSyncRule(BuildContext context) async {
final downloadProvider = Provider.of<DownloadProvider>(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<void> _handleRemoveSyncRule(BuildContext context) => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: _itemGlobalKey(),
displayTitle: _itemDisplayTitle(),
);
/// Handle delete media item action
/// This permanently removes the media item and its associated files from the server