feat: collection/playlist sync rules
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -764,7 +764,11 @@
|
||||
"activeSyncRules": "同期ルール",
|
||||
"noSyncRules": "同期ルールなし",
|
||||
"manageSyncRule": "同期を管理",
|
||||
"editEpisodeCount": "エピソード数"
|
||||
"editEpisodeCount": "エピソード数",
|
||||
"editSyncFilter": "同期フィルター",
|
||||
"syncAllItems": "すべてのアイテムを同期中",
|
||||
"syncUnwatchedItems": "未視聴のアイテムを同期中",
|
||||
"syncRuleListCreated": "同期ルールを作成しました"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "シェーダー",
|
||||
|
||||
@@ -764,7 +764,11 @@
|
||||
"activeSyncRules": "동기화 규칙",
|
||||
"noSyncRules": "동기화 규칙 없음",
|
||||
"manageSyncRule": "동기화 관리",
|
||||
"editEpisodeCount": "에피소드 수"
|
||||
"editEpisodeCount": "에피소드 수",
|
||||
"editSyncFilter": "동기화 필터",
|
||||
"syncAllItems": "모든 항목 동기화 중",
|
||||
"syncUnwatchedItems": "시청하지 않은 항목 동기화 중",
|
||||
"syncRuleListCreated": "동기화 규칙이 생성되었습니다"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "셰이더",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -764,7 +764,11 @@
|
||||
"activeSyncRules": "Правила синхронизации",
|
||||
"noSyncRules": "Нет правил синхронизации",
|
||||
"manageSyncRule": "Управление синхронизацией",
|
||||
"editEpisodeCount": "Количество эпизодов"
|
||||
"editEpisodeCount": "Количество эпизодов",
|
||||
"editSyncFilter": "Фильтр синхронизации",
|
||||
"syncAllItems": "Синхронизация всех элементов",
|
||||
"syncUnwatchedItems": "Синхронизация непросмотренных элементов",
|
||||
"syncRuleListCreated": "Правило синхронизации создано"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Шейдеры",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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画像スケーリング',
|
||||
|
||||
@@ -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 이미지 스케일링',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 для более чёткого видео',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 图像缩放,使视频更清晰',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -764,7 +764,11 @@
|
||||
"activeSyncRules": "同步规则",
|
||||
"noSyncRules": "没有同步规则",
|
||||
"manageSyncRule": "管理同步",
|
||||
"editEpisodeCount": "剧集数量"
|
||||
"editEpisodeCount": "剧集数量",
|
||||
"editSyncFilter": "同步筛选",
|
||||
"syncAllItems": "同步所有项目",
|
||||
"syncUnwatchedItems": "同步未观看项目",
|
||||
"syncRuleListCreated": "同步规则已创建"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "着色器",
|
||||
|
||||
+82
-10
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user