feat: add "Include Specials" toggle to the show download dialog
The aired-order rework can sweep correctly-placed Specials into "download next N"; this toggle lets users opt out. Shown only for whole shows (reusing FocusableSwitchListTile via an optional toggle on the shared option-picker dialog), remembered across opens via a BoolPref (default on = unchanged behavior). Filters Specials at the single collect choke point (_collectPlayable), with a guard so explicitly downloading the Specials season still queues its episodes.
This commit is contained in:
@@ -61,7 +61,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.forTesting(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 15;
|
||||
int get schemaVersion => 16;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -214,6 +214,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.mediaSourceId),
|
||||
);
|
||||
}
|
||||
if (from < 16) {
|
||||
appLogger.i('Adding includeSpecials column to SyncRules (v16 migration)');
|
||||
await _ignoreAlreadyExists(
|
||||
'SyncRules.includeSpecials column',
|
||||
() => m.addColumn(syncRules, syncRules.includeSpecials),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -532,6 +539,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
required int episodeCount,
|
||||
int mediaIndex = 0,
|
||||
String downloadFilter = 'unwatched',
|
||||
bool includeSpecials = true,
|
||||
}) async {
|
||||
// [insertOnConflictUpdate] defaults the conflict target to the primary
|
||||
// key (`id`), which is auto-incremented — the conflict never triggers
|
||||
@@ -549,6 +557,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
includeSpecials: Value(includeSpecials),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SyncRulesCompanion(
|
||||
@@ -559,6 +568,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
episodeCount: Value(episodeCount),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
includeSpecials: Value(includeSpecials),
|
||||
),
|
||||
target: [syncRules.globalKey],
|
||||
),
|
||||
|
||||
@@ -3150,6 +3150,21 @@ class $SyncRulesTable extends SyncRules
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant('unwatched'),
|
||||
);
|
||||
static const VerificationMeta _includeSpecialsMeta = const VerificationMeta(
|
||||
'includeSpecials',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> includeSpecials = GeneratedColumn<bool>(
|
||||
'include_specials',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("include_specials" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(true),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
@@ -3164,6 +3179,7 @@ class $SyncRulesTable extends SyncRules
|
||||
lastExecutedAt,
|
||||
mediaIndex,
|
||||
downloadFilter,
|
||||
includeSpecials,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -3267,6 +3283,15 @@ class $SyncRulesTable extends SyncRules
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('include_specials')) {
|
||||
context.handle(
|
||||
_includeSpecialsMeta,
|
||||
includeSpecials.isAcceptableOrUnknown(
|
||||
data['include_specials']!,
|
||||
_includeSpecialsMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -3324,6 +3349,10 @@ class $SyncRulesTable extends SyncRules
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}download_filter'],
|
||||
)!,
|
||||
includeSpecials: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}include_specials'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3346,6 +3375,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
final int? lastExecutedAt;
|
||||
final int mediaIndex;
|
||||
final String downloadFilter;
|
||||
final bool includeSpecials;
|
||||
const SyncRuleItem({
|
||||
required this.id,
|
||||
required this.profileId,
|
||||
@@ -3359,6 +3389,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
this.lastExecutedAt,
|
||||
required this.mediaIndex,
|
||||
required this.downloadFilter,
|
||||
required this.includeSpecials,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -3377,6 +3408,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
}
|
||||
map['media_index'] = Variable<int>(mediaIndex);
|
||||
map['download_filter'] = Variable<String>(downloadFilter);
|
||||
map['include_specials'] = Variable<bool>(includeSpecials);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -3396,6 +3428,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
: Value(lastExecutedAt),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
includeSpecials: Value(includeSpecials),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3417,6 +3450,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
lastExecutedAt: serializer.fromJson<int?>(json['lastExecutedAt']),
|
||||
mediaIndex: serializer.fromJson<int>(json['mediaIndex']),
|
||||
downloadFilter: serializer.fromJson<String>(json['downloadFilter']),
|
||||
includeSpecials: serializer.fromJson<bool>(json['includeSpecials']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -3435,6 +3469,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
'lastExecutedAt': serializer.toJson<int?>(lastExecutedAt),
|
||||
'mediaIndex': serializer.toJson<int>(mediaIndex),
|
||||
'downloadFilter': serializer.toJson<String>(downloadFilter),
|
||||
'includeSpecials': serializer.toJson<bool>(includeSpecials),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3451,6 +3486,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
Value<int?> lastExecutedAt = const Value.absent(),
|
||||
int? mediaIndex,
|
||||
String? downloadFilter,
|
||||
bool? includeSpecials,
|
||||
}) => SyncRuleItem(
|
||||
id: id ?? this.id,
|
||||
profileId: profileId ?? this.profileId,
|
||||
@@ -3466,6 +3502,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
: this.lastExecutedAt,
|
||||
mediaIndex: mediaIndex ?? this.mediaIndex,
|
||||
downloadFilter: downloadFilter ?? this.downloadFilter,
|
||||
includeSpecials: includeSpecials ?? this.includeSpecials,
|
||||
);
|
||||
SyncRuleItem copyWithCompanion(SyncRulesCompanion data) {
|
||||
return SyncRuleItem(
|
||||
@@ -3491,6 +3528,9 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
downloadFilter: data.downloadFilter.present
|
||||
? data.downloadFilter.value
|
||||
: this.downloadFilter,
|
||||
includeSpecials: data.includeSpecials.present
|
||||
? data.includeSpecials.value
|
||||
: this.includeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3508,7 +3548,8 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('lastExecutedAt: $lastExecutedAt, ')
|
||||
..write('mediaIndex: $mediaIndex, ')
|
||||
..write('downloadFilter: $downloadFilter')
|
||||
..write('downloadFilter: $downloadFilter, ')
|
||||
..write('includeSpecials: $includeSpecials')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -3527,6 +3568,7 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
lastExecutedAt,
|
||||
mediaIndex,
|
||||
downloadFilter,
|
||||
includeSpecials,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -3543,7 +3585,8 @@ class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
other.createdAt == this.createdAt &&
|
||||
other.lastExecutedAt == this.lastExecutedAt &&
|
||||
other.mediaIndex == this.mediaIndex &&
|
||||
other.downloadFilter == this.downloadFilter);
|
||||
other.downloadFilter == this.downloadFilter &&
|
||||
other.includeSpecials == this.includeSpecials);
|
||||
}
|
||||
|
||||
class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
@@ -3559,6 +3602,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
final Value<int?> lastExecutedAt;
|
||||
final Value<int> mediaIndex;
|
||||
final Value<String> downloadFilter;
|
||||
final Value<bool> includeSpecials;
|
||||
const SyncRulesCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.profileId = const Value.absent(),
|
||||
@@ -3572,6 +3616,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
this.lastExecutedAt = const Value.absent(),
|
||||
this.mediaIndex = const Value.absent(),
|
||||
this.downloadFilter = const Value.absent(),
|
||||
this.includeSpecials = const Value.absent(),
|
||||
});
|
||||
SyncRulesCompanion.insert({
|
||||
this.id = const Value.absent(),
|
||||
@@ -3586,6 +3631,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
this.lastExecutedAt = const Value.absent(),
|
||||
this.mediaIndex = const Value.absent(),
|
||||
this.downloadFilter = const Value.absent(),
|
||||
this.includeSpecials = const Value.absent(),
|
||||
}) : serverId = Value(serverId),
|
||||
ratingKey = Value(ratingKey),
|
||||
globalKey = Value(globalKey),
|
||||
@@ -3605,6 +3651,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
Expression<int>? lastExecutedAt,
|
||||
Expression<int>? mediaIndex,
|
||||
Expression<String>? downloadFilter,
|
||||
Expression<bool>? includeSpecials,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
@@ -3619,6 +3666,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
if (lastExecutedAt != null) 'last_executed_at': lastExecutedAt,
|
||||
if (mediaIndex != null) 'media_index': mediaIndex,
|
||||
if (downloadFilter != null) 'download_filter': downloadFilter,
|
||||
if (includeSpecials != null) 'include_specials': includeSpecials,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3635,6 +3683,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
Value<int?>? lastExecutedAt,
|
||||
Value<int>? mediaIndex,
|
||||
Value<String>? downloadFilter,
|
||||
Value<bool>? includeSpecials,
|
||||
}) {
|
||||
return SyncRulesCompanion(
|
||||
id: id ?? this.id,
|
||||
@@ -3649,6 +3698,7 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
lastExecutedAt: lastExecutedAt ?? this.lastExecutedAt,
|
||||
mediaIndex: mediaIndex ?? this.mediaIndex,
|
||||
downloadFilter: downloadFilter ?? this.downloadFilter,
|
||||
includeSpecials: includeSpecials ?? this.includeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3691,6 +3741,9 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
if (downloadFilter.present) {
|
||||
map['download_filter'] = Variable<String>(downloadFilter.value);
|
||||
}
|
||||
if (includeSpecials.present) {
|
||||
map['include_specials'] = Variable<bool>(includeSpecials.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -3708,7 +3761,8 @@ class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('lastExecutedAt: $lastExecutedAt, ')
|
||||
..write('mediaIndex: $mediaIndex, ')
|
||||
..write('downloadFilter: $downloadFilter')
|
||||
..write('downloadFilter: $downloadFilter, ')
|
||||
..write('includeSpecials: $includeSpecials')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -6842,6 +6896,7 @@ typedef $$SyncRulesTableCreateCompanionBuilder =
|
||||
Value<int?> lastExecutedAt,
|
||||
Value<int> mediaIndex,
|
||||
Value<String> downloadFilter,
|
||||
Value<bool> includeSpecials,
|
||||
});
|
||||
typedef $$SyncRulesTableUpdateCompanionBuilder =
|
||||
SyncRulesCompanion Function({
|
||||
@@ -6857,6 +6912,7 @@ typedef $$SyncRulesTableUpdateCompanionBuilder =
|
||||
Value<int?> lastExecutedAt,
|
||||
Value<int> mediaIndex,
|
||||
Value<String> downloadFilter,
|
||||
Value<bool> includeSpecials,
|
||||
});
|
||||
|
||||
class $$SyncRulesTableFilterComposer
|
||||
@@ -6927,6 +6983,11 @@ class $$SyncRulesTableFilterComposer
|
||||
column: $table.downloadFilter,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get includeSpecials => $composableBuilder(
|
||||
column: $table.includeSpecials,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SyncRulesTableOrderingComposer
|
||||
@@ -6997,6 +7058,11 @@ class $$SyncRulesTableOrderingComposer
|
||||
column: $table.downloadFilter,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get includeSpecials => $composableBuilder(
|
||||
column: $table.includeSpecials,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SyncRulesTableAnnotationComposer
|
||||
@@ -7053,6 +7119,11 @@ class $$SyncRulesTableAnnotationComposer
|
||||
column: $table.downloadFilter,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get includeSpecials => $composableBuilder(
|
||||
column: $table.includeSpecials,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$SyncRulesTableTableManager
|
||||
@@ -7098,6 +7169,7 @@ class $$SyncRulesTableTableManager
|
||||
Value<int?> lastExecutedAt = const Value.absent(),
|
||||
Value<int> mediaIndex = const Value.absent(),
|
||||
Value<String> downloadFilter = const Value.absent(),
|
||||
Value<bool> includeSpecials = const Value.absent(),
|
||||
}) => SyncRulesCompanion(
|
||||
id: id,
|
||||
profileId: profileId,
|
||||
@@ -7111,6 +7183,7 @@ class $$SyncRulesTableTableManager
|
||||
lastExecutedAt: lastExecutedAt,
|
||||
mediaIndex: mediaIndex,
|
||||
downloadFilter: downloadFilter,
|
||||
includeSpecials: includeSpecials,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -7126,6 +7199,7 @@ class $$SyncRulesTableTableManager
|
||||
Value<int?> lastExecutedAt = const Value.absent(),
|
||||
Value<int> mediaIndex = const Value.absent(),
|
||||
Value<String> downloadFilter = const Value.absent(),
|
||||
Value<bool> includeSpecials = const Value.absent(),
|
||||
}) => SyncRulesCompanion.insert(
|
||||
id: id,
|
||||
profileId: profileId,
|
||||
@@ -7139,6 +7213,7 @@ class $$SyncRulesTableTableManager
|
||||
lastExecutedAt: lastExecutedAt,
|
||||
mediaIndex: mediaIndex,
|
||||
downloadFilter: downloadFilter,
|
||||
includeSpecials: includeSpecials,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
|
||||
@@ -101,6 +101,7 @@ class SyncRules extends Table {
|
||||
IntColumn get lastExecutedAt => integer().nullable()();
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
|
||||
BoolColumn get includeSpecials => boolean().withDefault(const Constant(true))();
|
||||
}
|
||||
|
||||
/// Persisted media-server connections.
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Само негледани",
|
||||
"nextNUnwatched": "Следващите ${count} негледани",
|
||||
"customAmount": "Персонален брой...",
|
||||
"includeSpecials": "Включи специалните",
|
||||
"howManyEpisodes": "Колко епизода?",
|
||||
"itemsQueued": "${count} елемента са добавени в опашката за изтегляне",
|
||||
"keepSynced": "Поддържай синхронизирано",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Kun usete",
|
||||
"nextNUnwatched": "Næste ${count} usete",
|
||||
"customAmount": "Angiv antal...",
|
||||
"includeSpecials": "Inkludér specials",
|
||||
"howManyEpisodes": "Hvor mange episoder?",
|
||||
"itemsQueued": "${count} elementer sat i kø til download",
|
||||
"keepSynced": "Hold synkroniseret",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Nur ungesehene",
|
||||
"nextNUnwatched": "Nächste ${count} ungesehene",
|
||||
"customAmount": "Eigene Anzahl...",
|
||||
"includeSpecials": "Specials einschließen",
|
||||
"howManyEpisodes": "Wie viele Episoden?",
|
||||
"itemsQueued": "${count} Elemente zum Download eingereiht",
|
||||
"keepSynced": "Synchronisiert halten",
|
||||
|
||||
@@ -1040,6 +1040,7 @@
|
||||
"unwatchedOnly": "Unwatched only",
|
||||
"nextNUnwatched": "Next ${count} unwatched",
|
||||
"customAmount": "Custom amount...",
|
||||
"includeSpecials": "Include Specials",
|
||||
"howManyEpisodes": "How many episodes?",
|
||||
"itemsQueued": "${count} items queued for download",
|
||||
"keepSynced": "Keep synced",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Solo no vistos",
|
||||
"nextNUnwatched": "Próximos ${count} no vistos",
|
||||
"customAmount": "Cantidad personalizada...",
|
||||
"includeSpecials": "Incluir especiales",
|
||||
"howManyEpisodes": "¿Cuántos episodios?",
|
||||
"itemsQueued": "${count} elementos en cola de descarga",
|
||||
"keepSynced": "Mantener sincronizado",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Non vus uniquement",
|
||||
"nextNUnwatched": "${count} prochains non vus",
|
||||
"customAmount": "Quantité personnalisée...",
|
||||
"includeSpecials": "Inclure les spéciaux",
|
||||
"howManyEpisodes": "Combien d'épisodes ?",
|
||||
"itemsQueued": "${count} éléments mis en file d'attente",
|
||||
"keepSynced": "Garder synchronisé",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Solo non visti",
|
||||
"nextNUnwatched": "Prossimi ${count} non visti",
|
||||
"customAmount": "Quantità personalizzata...",
|
||||
"includeSpecials": "Includi gli speciali",
|
||||
"howManyEpisodes": "Quanti episodi?",
|
||||
"itemsQueued": "${count} elementi in coda per il download",
|
||||
"keepSynced": "Mantieni sincronizzato",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "未視聴のみ",
|
||||
"nextNUnwatched": "次の${count}件の未視聴",
|
||||
"customAmount": "数を指定...",
|
||||
"includeSpecials": "スペシャルを含める",
|
||||
"howManyEpisodes": "何エピソード?",
|
||||
"itemsQueued": "${count}件をダウンロードキューに追加",
|
||||
"keepSynced": "同期を維持",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "시청하지 않은 것만",
|
||||
"nextNUnwatched": "다음 ${count}개 미시청",
|
||||
"customAmount": "직접 입력...",
|
||||
"includeSpecials": "스페셜 포함",
|
||||
"howManyEpisodes": "몇 개의 에피소드?",
|
||||
"itemsQueued": "${count}개 항목이 다운로드 대기열에 추가됨",
|
||||
"keepSynced": "동기화 유지",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Kun usette",
|
||||
"nextNUnwatched": "Neste ${count} usette",
|
||||
"customAmount": "Egendefinert antall...",
|
||||
"includeSpecials": "Inkluder spesialepisoder",
|
||||
"howManyEpisodes": "Hvor mange episoder?",
|
||||
"itemsQueued": "${count} elementer i nedlastingskø",
|
||||
"keepSynced": "Hold synkronisert",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Alleen onbekeken",
|
||||
"nextNUnwatched": "Volgende ${count} onbekeken",
|
||||
"customAmount": "Aangepast aantal...",
|
||||
"includeSpecials": "Specials opnemen",
|
||||
"howManyEpisodes": "Hoeveel afleveringen?",
|
||||
"itemsQueued": "${count} items in downloadwachtrij",
|
||||
"keepSynced": "Gesynchroniseerd houden",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Tylko nieobejrzane",
|
||||
"nextNUnwatched": "Następne ${count} nieobejrzanych",
|
||||
"customAmount": "Własna ilość...",
|
||||
"includeSpecials": "Uwzględnij odcinki specjalne",
|
||||
"howManyEpisodes": "Ile odcinków?",
|
||||
"itemsQueued": "${count} elementów dodanych do kolejki pobierania",
|
||||
"keepSynced": "Synchronizuj na bieżąco",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Apenas não assistidos",
|
||||
"nextNUnwatched": "Próximos ${count} não assistidos",
|
||||
"customAmount": "Quantidade personalizada...",
|
||||
"includeSpecials": "Incluir especiais",
|
||||
"howManyEpisodes": "Quantos episódios?",
|
||||
"itemsQueued": "${count} itens na fila de download",
|
||||
"keepSynced": "Manter sincronizado",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Только непросмотренные",
|
||||
"nextNUnwatched": "Следующие ${count} непросмотренных",
|
||||
"customAmount": "Указать количество...",
|
||||
"includeSpecials": "Включить спецвыпуски",
|
||||
"howManyEpisodes": "Сколько эпизодов?",
|
||||
"itemsQueued": "${count} элементов добавлено в очередь загрузки",
|
||||
"keepSynced": "Синхронизировать",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 16
|
||||
/// Strings: 20425 (1276 per locale)
|
||||
/// Strings: 20441 (1277 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsBg extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Само негледани';
|
||||
@override String nextNUnwatched({required Object count}) => 'Следващите ${count} негледани';
|
||||
@override String get customAmount => 'Персонален брой...';
|
||||
@override String get includeSpecials => 'Включи специалните';
|
||||
@override String get howManyEpisodes => 'Колко епизода?';
|
||||
@override String itemsQueued({required Object count}) => '${count} елемента са добавени в опашката за изтегляне';
|
||||
@override String get keepSynced => 'Поддържай синхронизирано';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsBg {
|
||||
'downloads.unwatchedOnly' => 'Само негледани',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Следващите ${count} негледани',
|
||||
'downloads.customAmount' => 'Персонален брой...',
|
||||
'downloads.includeSpecials' => 'Включи специалните',
|
||||
'downloads.howManyEpisodes' => 'Колко епизода?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} елемента са добавени в опашката за изтегляне',
|
||||
'downloads.keepSynced' => 'Поддържай синхронизирано',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsBg {
|
||||
'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Неуспешно свързване: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Искате ли да прекъснете връзката с дистанционната сесия?',
|
||||
'companionRemote.remote.reconnecting' => 'Повторно свързване...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Повторно свързване...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Опит ${current} от 5',
|
||||
'companionRemote.remote.retryNow' => 'Опитай сега',
|
||||
'companionRemote.remote.tabRemote' => 'Дистанционно',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsDa extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Kun usete';
|
||||
@override String nextNUnwatched({required Object count}) => 'Næste ${count} usete';
|
||||
@override String get customAmount => 'Angiv antal...';
|
||||
@override String get includeSpecials => 'Inkludér specials';
|
||||
@override String get howManyEpisodes => 'Hvor mange episoder?';
|
||||
@override String itemsQueued({required Object count}) => '${count} elementer sat i kø til download';
|
||||
@override String get keepSynced => 'Hold synkroniseret';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsDa {
|
||||
'downloads.unwatchedOnly' => 'Kun usete',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Næste ${count} usete',
|
||||
'downloads.customAmount' => 'Angiv antal...',
|
||||
'downloads.includeSpecials' => 'Inkludér specials',
|
||||
'downloads.howManyEpisodes' => 'Hvor mange episoder?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementer sat i kø til download',
|
||||
'downloads.keepSynced' => 'Hold synkroniseret',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsDa {
|
||||
'companionRemote.pairing.authFailed' => 'Godkendelse mislykkedes. Begge enheder skal bruge samme Plex-konto.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke oprette forbindelse: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Vil du afbryde fra fjernsessionen?',
|
||||
'companionRemote.remote.reconnecting' => 'Genopretter forbindelse...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Genopretter forbindelse...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Forsøg ${current} af 5',
|
||||
'companionRemote.remote.retryNow' => 'Prøv igen nu',
|
||||
'companionRemote.remote.tabRemote' => 'Fjernbetjening',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsDe extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Nur ungesehene';
|
||||
@override String nextNUnwatched({required Object count}) => 'Nächste ${count} ungesehene';
|
||||
@override String get customAmount => 'Eigene Anzahl...';
|
||||
@override String get includeSpecials => 'Specials einschließen';
|
||||
@override String get howManyEpisodes => 'Wie viele Episoden?';
|
||||
@override String itemsQueued({required Object count}) => '${count} Elemente zum Download eingereiht';
|
||||
@override String get keepSynced => 'Synchronisiert halten';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsDe {
|
||||
'downloads.unwatchedOnly' => 'Nur ungesehene',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Nächste ${count} ungesehene',
|
||||
'downloads.customAmount' => 'Eigene Anzahl...',
|
||||
'downloads.includeSpecials' => 'Specials einschließen',
|
||||
'downloads.howManyEpisodes' => 'Wie viele Episoden?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} Elemente zum Download eingereiht',
|
||||
'downloads.keepSynced' => 'Synchronisiert halten',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsDe {
|
||||
'companionRemote.pairing.authFailed' => 'Authentifizierung fehlgeschlagen. Beide Geräte benötigen dasselbe Plex-Konto.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?',
|
||||
'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Versuch ${current} von 5',
|
||||
'companionRemote.remote.retryNow' => 'Jetzt wiederholen',
|
||||
'companionRemote.remote.tabRemote' => 'Fernbedienung',
|
||||
|
||||
@@ -3084,6 +3084,9 @@ class TranslationsDownloadsEn {
|
||||
/// en: 'Custom amount...'
|
||||
String get customAmount => 'Custom amount...';
|
||||
|
||||
/// en: 'Include Specials'
|
||||
String get includeSpecials => 'Include Specials';
|
||||
|
||||
/// en: 'How many episodes?'
|
||||
String get howManyEpisodes => 'How many episodes?';
|
||||
|
||||
@@ -5472,6 +5475,7 @@ extension on Translations {
|
||||
'downloads.unwatchedOnly' => 'Unwatched only',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Next ${count} unwatched',
|
||||
'downloads.customAmount' => 'Custom amount...',
|
||||
'downloads.includeSpecials' => 'Include Specials',
|
||||
'downloads.howManyEpisodes' => 'How many episodes?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download',
|
||||
'downloads.keepSynced' => 'Keep synced',
|
||||
@@ -5535,9 +5539,9 @@ extension on Translations {
|
||||
'companionRemote.pairing.noDevicesFound' => 'No devices found on your network',
|
||||
'companionRemote.pairing.noDevicesHint' => 'Open Plezy on desktop and use the same WiFi',
|
||||
'companionRemote.pairing.availableDevices' => 'Available Devices',
|
||||
'companionRemote.pairing.manualConnection' => 'Manual Connection',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.pairing.manualConnection' => 'Manual Connection',
|
||||
'companionRemote.pairing.cryptoInitFailed' => 'Couldn\'t start secure connection. Sign in to Plex first.',
|
||||
'companionRemote.pairing.validationHostRequired' => 'Please enter host address',
|
||||
'companionRemote.pairing.validationHostFormat' => 'Format must be IP:port (e.g., 192.168.1.100:48632)',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsEs extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Solo no vistos';
|
||||
@override String nextNUnwatched({required Object count}) => 'Próximos ${count} no vistos';
|
||||
@override String get customAmount => 'Cantidad personalizada...';
|
||||
@override String get includeSpecials => 'Incluir especiales';
|
||||
@override String get howManyEpisodes => '¿Cuántos episodios?';
|
||||
@override String itemsQueued({required Object count}) => '${count} elementos en cola de descarga';
|
||||
@override String get keepSynced => 'Mantener sincronizado';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsEs {
|
||||
'downloads.unwatchedOnly' => 'Solo no vistos',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Próximos ${count} no vistos',
|
||||
'downloads.customAmount' => 'Cantidad personalizada...',
|
||||
'downloads.includeSpecials' => 'Incluir especiales',
|
||||
'downloads.howManyEpisodes' => '¿Cuántos episodios?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementos en cola de descarga',
|
||||
'downloads.keepSynced' => 'Mantener sincronizado',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsEs {
|
||||
'companionRemote.pairing.authFailed' => 'Autenticación fallida. Ambos dispositivos necesitan la misma cuenta Plex.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?',
|
||||
'companionRemote.remote.reconnecting' => 'Reconectando...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Reconectando...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Intento ${current} de 5',
|
||||
'companionRemote.remote.retryNow' => 'Reintentar ahora',
|
||||
'companionRemote.remote.tabRemote' => 'Remoto',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsFr extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Non vus uniquement';
|
||||
@override String nextNUnwatched({required Object count}) => '${count} prochains non vus';
|
||||
@override String get customAmount => 'Quantité personnalisée...';
|
||||
@override String get includeSpecials => 'Inclure les spéciaux';
|
||||
@override String get howManyEpisodes => 'Combien d\'épisodes ?';
|
||||
@override String itemsQueued({required Object count}) => '${count} éléments mis en file d\'attente';
|
||||
@override String get keepSynced => 'Garder synchronisé';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsFr {
|
||||
'downloads.unwatchedOnly' => 'Non vus uniquement',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => '${count} prochains non vus',
|
||||
'downloads.customAmount' => 'Quantité personnalisée...',
|
||||
'downloads.includeSpecials' => 'Inclure les spéciaux',
|
||||
'downloads.howManyEpisodes' => 'Combien d\'épisodes ?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} éléments mis en file d\'attente',
|
||||
'downloads.keepSynced' => 'Garder synchronisé',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsFr {
|
||||
'companionRemote.pairing.authFailed' => 'Échec de l\'authentification. Les deux appareils doivent utiliser le même compte Plex.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?',
|
||||
'companionRemote.remote.reconnecting' => 'Reconnexion...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Reconnexion...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentative ${current} sur 5',
|
||||
'companionRemote.remote.retryNow' => 'Réessayer maintenant',
|
||||
'companionRemote.remote.tabRemote' => 'Télécommande',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsIt extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Solo non visti';
|
||||
@override String nextNUnwatched({required Object count}) => 'Prossimi ${count} non visti';
|
||||
@override String get customAmount => 'Quantità personalizzata...';
|
||||
@override String get includeSpecials => 'Includi gli speciali';
|
||||
@override String get howManyEpisodes => 'Quanti episodi?';
|
||||
@override String itemsQueued({required Object count}) => '${count} elementi in coda per il download';
|
||||
@override String get keepSynced => 'Mantieni sincronizzato';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsIt {
|
||||
'downloads.unwatchedOnly' => 'Solo non visti',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Prossimi ${count} non visti',
|
||||
'downloads.customAmount' => 'Quantità personalizzata...',
|
||||
'downloads.includeSpecials' => 'Includi gli speciali',
|
||||
'downloads.howManyEpisodes' => 'Quanti episodi?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementi in coda per il download',
|
||||
'downloads.keepSynced' => 'Mantieni sincronizzato',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsIt {
|
||||
'companionRemote.pairing.authFailed' => 'Autenticazione non riuscita. Entrambi i dispositivi devono usare lo stesso account Plex.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Connessione fallita: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?',
|
||||
'companionRemote.remote.reconnecting' => 'Riconnessione...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Riconnessione...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativo ${current} di 5',
|
||||
'companionRemote.remote.retryNow' => 'Riprova ora',
|
||||
'companionRemote.remote.tabRemote' => 'Telecomando',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsJa extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => '未視聴のみ';
|
||||
@override String nextNUnwatched({required Object count}) => '次の${count}件の未視聴';
|
||||
@override String get customAmount => '数を指定...';
|
||||
@override String get includeSpecials => 'スペシャルを含める';
|
||||
@override String get howManyEpisodes => '何エピソード?';
|
||||
@override String itemsQueued({required Object count}) => '${count}件をダウンロードキューに追加';
|
||||
@override String get keepSynced => '同期を維持';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsJa {
|
||||
'downloads.unwatchedOnly' => '未視聴のみ',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => '次の${count}件の未視聴',
|
||||
'downloads.customAmount' => '数を指定...',
|
||||
'downloads.includeSpecials' => 'スペシャルを含める',
|
||||
'downloads.howManyEpisodes' => '何エピソード?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count}件をダウンロードキューに追加',
|
||||
'downloads.keepSynced' => '同期を維持',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsJa {
|
||||
'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '接続に失敗しました: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'リモートセッションから切断しますか?',
|
||||
'companionRemote.remote.reconnecting' => '再接続中...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => '再接続中...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => '試行 ${current}/5',
|
||||
'companionRemote.remote.retryNow' => '今すぐ再試行',
|
||||
'companionRemote.remote.tabRemote' => 'リモート',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsKo extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => '시청하지 않은 것만';
|
||||
@override String nextNUnwatched({required Object count}) => '다음 ${count}개 미시청';
|
||||
@override String get customAmount => '직접 입력...';
|
||||
@override String get includeSpecials => '스페셜 포함';
|
||||
@override String get howManyEpisodes => '몇 개의 에피소드?';
|
||||
@override String itemsQueued({required Object count}) => '${count}개 항목이 다운로드 대기열에 추가됨';
|
||||
@override String get keepSynced => '동기화 유지';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsKo {
|
||||
'downloads.unwatchedOnly' => '시청하지 않은 것만',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => '다음 ${count}개 미시청',
|
||||
'downloads.customAmount' => '직접 입력...',
|
||||
'downloads.includeSpecials' => '스페셜 포함',
|
||||
'downloads.howManyEpisodes' => '몇 개의 에피소드?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count}개 항목이 다운로드 대기열에 추가됨',
|
||||
'downloads.keepSynced' => '동기화 유지',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsKo {
|
||||
'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?',
|
||||
'companionRemote.remote.reconnecting' => '재연결 중...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => '재연결 중...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current}/5 시도 중',
|
||||
'companionRemote.remote.retryNow' => '지금 재시도',
|
||||
'companionRemote.remote.tabRemote' => '리모컨',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsNb extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Kun usette';
|
||||
@override String nextNUnwatched({required Object count}) => 'Neste ${count} usette';
|
||||
@override String get customAmount => 'Egendefinert antall...';
|
||||
@override String get includeSpecials => 'Inkluder spesialepisoder';
|
||||
@override String get howManyEpisodes => 'Hvor mange episoder?';
|
||||
@override String itemsQueued({required Object count}) => '${count} elementer i nedlastingskø';
|
||||
@override String get keepSynced => 'Hold synkronisert';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsNb {
|
||||
'downloads.unwatchedOnly' => 'Kun usette',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Neste ${count} usette',
|
||||
'downloads.customAmount' => 'Egendefinert antall...',
|
||||
'downloads.includeSpecials' => 'Inkluder spesialepisoder',
|
||||
'downloads.howManyEpisodes' => 'Hvor mange episoder?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementer i nedlastingskø',
|
||||
'downloads.keepSynced' => 'Hold synkronisert',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsNb {
|
||||
'companionRemote.pairing.authFailed' => 'Autentisering mislyktes. Begge enheter må bruke samme Plex-konto.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke koble til: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Vil du koble fra fjernøkten?',
|
||||
'companionRemote.remote.reconnecting' => 'Kobler til på nytt...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Kobler til på nytt...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Forsøk ${current} av 5',
|
||||
'companionRemote.remote.retryNow' => 'Prøv nå',
|
||||
'companionRemote.remote.tabRemote' => 'Fjernkontroll',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsNl extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Alleen onbekeken';
|
||||
@override String nextNUnwatched({required Object count}) => 'Volgende ${count} onbekeken';
|
||||
@override String get customAmount => 'Aangepast aantal...';
|
||||
@override String get includeSpecials => 'Specials opnemen';
|
||||
@override String get howManyEpisodes => 'Hoeveel afleveringen?';
|
||||
@override String itemsQueued({required Object count}) => '${count} items in downloadwachtrij';
|
||||
@override String get keepSynced => 'Gesynchroniseerd houden';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsNl {
|
||||
'downloads.unwatchedOnly' => 'Alleen onbekeken',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Volgende ${count} onbekeken',
|
||||
'downloads.customAmount' => 'Aangepast aantal...',
|
||||
'downloads.includeSpecials' => 'Specials opnemen',
|
||||
'downloads.howManyEpisodes' => 'Hoeveel afleveringen?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} items in downloadwachtrij',
|
||||
'downloads.keepSynced' => 'Gesynchroniseerd houden',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsNl {
|
||||
'companionRemote.pairing.authFailed' => 'Authenticatie mislukt. Beide apparaten hebben hetzelfde Plex-account nodig.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kan niet verbinden: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?',
|
||||
'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Poging ${current} van 5',
|
||||
'companionRemote.remote.retryNow' => 'Nu opnieuw proberen',
|
||||
'companionRemote.remote.tabRemote' => 'Afstandsbediening',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsPl extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Tylko nieobejrzane';
|
||||
@override String nextNUnwatched({required Object count}) => 'Następne ${count} nieobejrzanych';
|
||||
@override String get customAmount => 'Własna ilość...';
|
||||
@override String get includeSpecials => 'Uwzględnij odcinki specjalne';
|
||||
@override String get howManyEpisodes => 'Ile odcinków?';
|
||||
@override String itemsQueued({required Object count}) => '${count} elementów dodanych do kolejki pobierania';
|
||||
@override String get keepSynced => 'Synchronizuj na bieżąco';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsPl {
|
||||
'downloads.unwatchedOnly' => 'Tylko nieobejrzane',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Następne ${count} nieobejrzanych',
|
||||
'downloads.customAmount' => 'Własna ilość...',
|
||||
'downloads.includeSpecials' => 'Uwzględnij odcinki specjalne',
|
||||
'downloads.howManyEpisodes' => 'Ile odcinków?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementów dodanych do kolejki pobierania',
|
||||
'downloads.keepSynced' => 'Synchronizuj na bieżąco',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsPl {
|
||||
'companionRemote.pairing.authFailed' => 'Uwierzytelnianie nie powiodło się. Oba urządzenia muszą używać tego samego konta Plex.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Nie udało się połączyć: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Czy chcesz się rozłączyć od sesji zdalnej?',
|
||||
'companionRemote.remote.reconnecting' => 'Ponowne łączenie...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Ponowne łączenie...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Próba ${current} z 5',
|
||||
'companionRemote.remote.retryNow' => 'Ponów teraz',
|
||||
'companionRemote.remote.tabRemote' => 'Pilot',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsPt extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Apenas não assistidos';
|
||||
@override String nextNUnwatched({required Object count}) => 'Próximos ${count} não assistidos';
|
||||
@override String get customAmount => 'Quantidade personalizada...';
|
||||
@override String get includeSpecials => 'Incluir especiais';
|
||||
@override String get howManyEpisodes => 'Quantos episódios?';
|
||||
@override String itemsQueued({required Object count}) => '${count} itens na fila de download';
|
||||
@override String get keepSynced => 'Manter sincronizado';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsPt {
|
||||
'downloads.unwatchedOnly' => 'Apenas não assistidos',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Próximos ${count} não assistidos',
|
||||
'downloads.customAmount' => 'Quantidade personalizada...',
|
||||
'downloads.includeSpecials' => 'Incluir especiais',
|
||||
'downloads.howManyEpisodes' => 'Quantos episódios?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} itens na fila de download',
|
||||
'downloads.keepSynced' => 'Manter sincronizado',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsPt {
|
||||
'companionRemote.pairing.authFailed' => 'Falha na autenticação. Ambos os dispositivos precisam da mesma conta Plex.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Falha ao conectar: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Deseja desconectar da sessão remota?',
|
||||
'companionRemote.remote.reconnecting' => 'Reconectando...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Reconectando...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativa ${current} de 5',
|
||||
'companionRemote.remote.retryNow' => 'Tentar Agora',
|
||||
'companionRemote.remote.tabRemote' => 'Remoto',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsRu extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Только непросмотренные';
|
||||
@override String nextNUnwatched({required Object count}) => 'Следующие ${count} непросмотренных';
|
||||
@override String get customAmount => 'Указать количество...';
|
||||
@override String get includeSpecials => 'Включить спецвыпуски';
|
||||
@override String get howManyEpisodes => 'Сколько эпизодов?';
|
||||
@override String itemsQueued({required Object count}) => '${count} элементов добавлено в очередь загрузки';
|
||||
@override String get keepSynced => 'Синхронизировать';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsRu {
|
||||
'downloads.unwatchedOnly' => 'Только непросмотренные',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Следующие ${count} непросмотренных',
|
||||
'downloads.customAmount' => 'Указать количество...',
|
||||
'downloads.includeSpecials' => 'Включить спецвыпуски',
|
||||
'downloads.howManyEpisodes' => 'Сколько эпизодов?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} элементов добавлено в очередь загрузки',
|
||||
'downloads.keepSynced' => 'Синхронизировать',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsRu {
|
||||
'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Не удалось подключиться: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Отключиться от удалённой сессии?',
|
||||
'companionRemote.remote.reconnecting' => 'Переподключение...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Переподключение...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Попытка ${current} из 5',
|
||||
'companionRemote.remote.retryNow' => 'Повторить сейчас',
|
||||
'companionRemote.remote.tabRemote' => 'Пульт',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsSv extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => 'Endast osedda';
|
||||
@override String nextNUnwatched({required Object count}) => 'Nästa ${count} osedda';
|
||||
@override String get customAmount => 'Ange antal...';
|
||||
@override String get includeSpecials => 'Inkludera specialavsnitt';
|
||||
@override String get howManyEpisodes => 'Hur många avsnitt?';
|
||||
@override String itemsQueued({required Object count}) => '${count} objekt köade för nedladdning';
|
||||
@override String get keepSynced => 'Håll synkroniserad';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsSv {
|
||||
'downloads.unwatchedOnly' => 'Endast osedda',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => 'Nästa ${count} osedda',
|
||||
'downloads.customAmount' => 'Ange antal...',
|
||||
'downloads.includeSpecials' => 'Inkludera specialavsnitt',
|
||||
'downloads.howManyEpisodes' => 'Hur många avsnitt?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} objekt köade för nedladdning',
|
||||
'downloads.keepSynced' => 'Håll synkroniserad',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsSv {
|
||||
'companionRemote.pairing.authFailed' => 'Autentisering misslyckades. Båda enheter behöver samma Plex-konto.',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?',
|
||||
'companionRemote.remote.reconnecting' => 'Återansluter...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => 'Återansluter...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Försök ${current} av 5',
|
||||
'companionRemote.remote.retryNow' => 'Försök nu',
|
||||
'companionRemote.remote.tabRemote' => 'Fjärrkontroll',
|
||||
|
||||
@@ -1288,6 +1288,7 @@ class _TranslationsDownloadsZh extends TranslationsDownloadsEn {
|
||||
@override String get unwatchedOnly => '仅未观看';
|
||||
@override String nextNUnwatched({required Object count}) => '接下来 ${count} 集未观看';
|
||||
@override String get customAmount => '自定义数量...';
|
||||
@override String get includeSpecials => '包含特别篇';
|
||||
@override String get howManyEpisodes => '下载几集?';
|
||||
@override String itemsQueued({required Object count}) => '${count} 个项目已加入下载队列';
|
||||
@override String get keepSynced => '保持同步';
|
||||
@@ -2878,6 +2879,7 @@ extension on TranslationsZh {
|
||||
'downloads.unwatchedOnly' => '仅未观看',
|
||||
'downloads.nextNUnwatched' => ({required Object count}) => '接下来 ${count} 集未观看',
|
||||
'downloads.customAmount' => '自定义数量...',
|
||||
'downloads.includeSpecials' => '包含特别篇',
|
||||
'downloads.howManyEpisodes' => '下载几集?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} 个项目已加入下载队列',
|
||||
'downloads.keepSynced' => '保持同步',
|
||||
@@ -2950,9 +2952,9 @@ extension on TranslationsZh {
|
||||
'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。',
|
||||
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}',
|
||||
'companionRemote.remote.disconnectConfirm' => '是否要断开远程会话的连接?',
|
||||
'companionRemote.remote.reconnecting' => '重新连接中...',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'companionRemote.remote.reconnecting' => '重新连接中...',
|
||||
'companionRemote.remote.attemptOf' => ({required Object current}) => '第 ${current} 次尝试,共 5 次',
|
||||
'companionRemote.remote.retryNow' => '立即重试',
|
||||
'companionRemote.remote.tabRemote' => '遥控',
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "Endast osedda",
|
||||
"nextNUnwatched": "Nästa ${count} osedda",
|
||||
"customAmount": "Ange antal...",
|
||||
"includeSpecials": "Inkludera specialavsnitt",
|
||||
"howManyEpisodes": "Hur många avsnitt?",
|
||||
"itemsQueued": "${count} objekt köade för nedladdning",
|
||||
"keepSynced": "Håll synkroniserad",
|
||||
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"unwatchedOnly": "仅未观看",
|
||||
"nextNUnwatched": "接下来 ${count} 集未观看",
|
||||
"customAmount": "自定义数量...",
|
||||
"includeSpecials": "包含特别篇",
|
||||
"howManyEpisodes": "下载几集?",
|
||||
"itemsQueued": "${count} 个项目已加入下载队列",
|
||||
"keepSynced": "保持同步",
|
||||
|
||||
@@ -20,8 +20,16 @@ Future<void> collectEpisodesForShow(
|
||||
required bool unwatchedOnly,
|
||||
required List<MediaItem> out,
|
||||
MediaItem? fallback,
|
||||
bool includeSpecials = true,
|
||||
}) {
|
||||
return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback);
|
||||
return _collectPlayable(
|
||||
client,
|
||||
showRatingKey,
|
||||
unwatchedOnly: unwatchedOnly,
|
||||
out: out,
|
||||
fallback: fallback,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
/// Collect every episode of a single season into [out] via the same
|
||||
@@ -33,8 +41,16 @@ Future<void> collectEpisodesForSeason(
|
||||
required bool unwatchedOnly,
|
||||
required List<MediaItem> out,
|
||||
MediaItem? fallback,
|
||||
bool includeSpecials = true,
|
||||
}) {
|
||||
return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback);
|
||||
return _collectPlayable(
|
||||
client,
|
||||
seasonRatingKey,
|
||||
unwatchedOnly: unwatchedOnly,
|
||||
out: out,
|
||||
fallback: fallback,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch just the first episode of a season without walking the entire season.
|
||||
@@ -253,6 +269,7 @@ Future<void> _collectPlayable(
|
||||
required bool unwatchedOnly,
|
||||
required List<MediaItem> out,
|
||||
MediaItem? fallback,
|
||||
bool includeSpecials = true,
|
||||
}) async {
|
||||
final leaves = await client.fetchPlayableDescendants(parentId);
|
||||
// Collect into a local list and order it before handing back: the backend
|
||||
@@ -266,6 +283,7 @@ Future<void> _collectPlayable(
|
||||
final collected = <MediaItem>[];
|
||||
for (final ep in leaves) {
|
||||
if (ep.kind != MediaKind.episode) continue;
|
||||
if (!includeSpecials && isSpecialSeasonNumber(ep.parentIndex)) continue;
|
||||
if (unwatchedOnly && !ep.isUnwatchedOrInProgress) continue;
|
||||
collected.add(_withFallbackLibrary(ep, fallback));
|
||||
}
|
||||
|
||||
@@ -845,6 +845,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadVersionConfig? versionConfig,
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
int? maxCount,
|
||||
bool includeSpecials = true,
|
||||
}) async {
|
||||
if (!_downloadManager.downloadsSupported) return 0;
|
||||
|
||||
@@ -870,7 +871,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
final hadMetadata = _metadata.containsKey(globalKey);
|
||||
_metadata[globalKey] = metadata;
|
||||
try {
|
||||
return await _queueShowDownload(metadata, client, versionConfig: config, filter: filter, maxCount: maxCount);
|
||||
return await _queueShowDownload(
|
||||
metadata,
|
||||
client,
|
||||
versionConfig: config,
|
||||
filter: filter,
|
||||
maxCount: maxCount,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!hadMetadata) _metadata.remove(globalKey);
|
||||
rethrow;
|
||||
@@ -885,6 +893,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
versionConfig: config,
|
||||
filter: filter,
|
||||
maxCount: maxCount,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!hadMetadata) _metadata.remove(globalKey);
|
||||
@@ -1120,6 +1129,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadVersionConfig? versionConfig,
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
int? maxCount,
|
||||
bool includeSpecials = true,
|
||||
}) async {
|
||||
return _expandAndQueue(
|
||||
container: show,
|
||||
@@ -1128,6 +1138,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
filter: filter,
|
||||
maxCount: maxCount,
|
||||
skipExisting: false,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1138,6 +1149,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadVersionConfig? versionConfig,
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
int? maxCount,
|
||||
bool includeSpecials = true,
|
||||
}) async {
|
||||
return _expandAndQueue(
|
||||
container: season,
|
||||
@@ -1146,6 +1158,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
filter: filter,
|
||||
maxCount: maxCount,
|
||||
skipExisting: false,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1183,8 +1196,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
required DownloadFilter filter,
|
||||
required int? maxCount,
|
||||
required bool skipExisting,
|
||||
bool includeSpecials = true,
|
||||
}) async {
|
||||
final unwatchedOnly = filter == DownloadFilter.unwatched;
|
||||
// Downloading the Specials season itself must still queue its episodes —
|
||||
// only suppress Specials when sweeping a whole show or a regular season.
|
||||
final effectiveIncludeSpecials =
|
||||
includeSpecials || (container.kind == MediaKind.season && isSpecialSeasonNumber(container.index));
|
||||
final relatedContext = _RelatedMetadataDownloadContext();
|
||||
final episodes = <MediaItem>[];
|
||||
if (container.kind == MediaKind.show) {
|
||||
@@ -1194,6 +1212,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
unwatchedOnly: unwatchedOnly,
|
||||
out: episodes,
|
||||
fallback: container,
|
||||
includeSpecials: effectiveIncludeSpecials,
|
||||
);
|
||||
} else {
|
||||
await collectEpisodesForSeason(
|
||||
@@ -1202,6 +1221,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
unwatchedOnly: unwatchedOnly,
|
||||
out: episodes,
|
||||
fallback: container,
|
||||
includeSpecials: effectiveIncludeSpecials,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1552,6 +1572,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
required int episodeCount,
|
||||
int mediaIndex = 0,
|
||||
String downloadFilter = SyncRuleFilter.unwatched,
|
||||
bool includeSpecials = true,
|
||||
MediaItem? targetMetadata,
|
||||
}) async {
|
||||
final profileId = _requireActiveProfileId();
|
||||
@@ -1566,6 +1587,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
episodeCount: episodeCount,
|
||||
mediaIndex: mediaIndex,
|
||||
downloadFilter: downloadFilter,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
|
||||
if (targetMetadata != null) {
|
||||
@@ -1579,7 +1601,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
_syncRules[rule.globalKey] = rule;
|
||||
safeNotifyListeners();
|
||||
}
|
||||
appLogger.i('Created sync rule: $scopedGlobalKey ($targetType, filter=$downloadFilter, keep $episodeCount)');
|
||||
appLogger.i(
|
||||
'Created sync rule: $scopedGlobalKey '
|
||||
'($targetType, filter=$downloadFilter, keep $episodeCount, includeSpecials=$includeSpecials)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Update the episode count for an existing show/season sync rule.
|
||||
|
||||
@@ -383,6 +383,11 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const customDownloadPathType = NullableStringPref('custom_download_path_type');
|
||||
static const downloadOnWifiOnly = BoolPref('download_on_wifi_only');
|
||||
static const autoRemoveWatchedDownloads = BoolPref('auto_remove_watched_downloads');
|
||||
|
||||
/// Remembered state of the "Include Specials" toggle on the show download
|
||||
/// dialog. Defaults to true (include) so existing behavior is unchanged;
|
||||
/// turning it off persists so the next download keeps the choice.
|
||||
static const downloadIncludeSpecials = BoolPref('download_include_specials', defaultValue: true);
|
||||
static const autoCheckUpdatesOnStartup = BoolPref('auto_check_updates_on_startup', defaultValue: true);
|
||||
static const showPerformanceOverlay = BoolPref('show_performance_overlay');
|
||||
static const autoHidePerformanceOverlay = BoolPref('auto_hide_performance_overlay', defaultValue: true);
|
||||
@@ -798,6 +803,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
rememberTrackSelections,
|
||||
customDownloadPathType,
|
||||
downloadOnWifiOnly,
|
||||
downloadIncludeSpecials,
|
||||
autoCheckUpdatesOnStartup,
|
||||
showPerformanceOverlay,
|
||||
autoHidePerformanceOverlay,
|
||||
|
||||
@@ -261,6 +261,7 @@ class SyncRuleExecutor {
|
||||
unwatchedOnly: true,
|
||||
out: fromServer,
|
||||
fallback: sourceMetadata,
|
||||
includeSpecials: rule.includeSpecials,
|
||||
);
|
||||
} else {
|
||||
await collectEpisodesForSeason(
|
||||
|
||||
+44
-18
@@ -322,11 +322,17 @@ class _TextInputDialogState extends State<_TextInputDialog>
|
||||
/// Returns the selected value, or null if cancelled. Each option's [icon] may
|
||||
/// be `null` to render a label-only row (useful when the choices are variants
|
||||
/// of the same thing and a repeated icon would just be noise).
|
||||
/// Optional persistent toggle rendered above the option rows. Its state is held
|
||||
/// by the dialog (toggling does not pop), and [onChanged] mirrors the new value
|
||||
/// out so the caller can read it once an option row is picked.
|
||||
typedef OptionPickerToggle = ({String label, IconData? icon, bool value, ValueChanged<bool> onChanged});
|
||||
|
||||
Future<T?> showOptionPickerDialog<T>(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<({IconData? icon, String label, T value})> options,
|
||||
Future<T?> Function(T value)? onBeforeClose,
|
||||
OptionPickerToggle? toggle,
|
||||
}) {
|
||||
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
|
||||
return showScopedDialog<T>(
|
||||
@@ -336,6 +342,7 @@ Future<T?> showOptionPickerDialog<T>(
|
||||
options: options,
|
||||
focusFirstItem: focusFirstItem,
|
||||
onBeforeClose: onBeforeClose,
|
||||
toggle: toggle,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -345,12 +352,14 @@ class _OptionPickerDialog<T> extends StatefulWidget {
|
||||
final List<({IconData? icon, String label, T value})> options;
|
||||
final bool focusFirstItem;
|
||||
final Future<T?> Function(T value)? onBeforeClose;
|
||||
final OptionPickerToggle? toggle;
|
||||
|
||||
const _OptionPickerDialog({
|
||||
required this.title,
|
||||
required this.options,
|
||||
this.focusFirstItem = false,
|
||||
this.onBeforeClose,
|
||||
this.toggle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -359,11 +368,13 @@ class _OptionPickerDialog<T> extends StatefulWidget {
|
||||
|
||||
class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
|
||||
late final FocusNode _initialFocusNode;
|
||||
late bool _toggleValue;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialFocusNode = FocusNode(debugLabel: 'OptionPickerInitialFocus');
|
||||
_toggleValue = widget.toggle?.value ?? false;
|
||||
if (widget.focusFirstItem) {
|
||||
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
|
||||
}
|
||||
@@ -377,27 +388,42 @@ class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const rowPadding = EdgeInsets.symmetric(horizontal: 24, vertical: 4);
|
||||
final toggle = widget.toggle;
|
||||
return SimpleDialog(
|
||||
title: Text(widget.title),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: List.generate(widget.options.length, (index) {
|
||||
final option = widget.options[index];
|
||||
final icon = option.icon;
|
||||
return FocusableListTile(
|
||||
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
|
||||
leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null,
|
||||
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
onTap: () async {
|
||||
if (widget.onBeforeClose != null) {
|
||||
final result = await widget.onBeforeClose!(option.value);
|
||||
if (context.mounted) Navigator.pop(context, result);
|
||||
} else {
|
||||
Navigator.pop(context, option.value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
children: [
|
||||
if (toggle != null)
|
||||
FocusableSwitchListTile(
|
||||
value: _toggleValue,
|
||||
secondary: toggle.icon != null ? AppIcon(toggle.icon!, fill: 1, size: 24) : null,
|
||||
title: Text(toggle.label, style: Theme.of(context).textTheme.bodyLarge),
|
||||
contentPadding: rowPadding,
|
||||
onChanged: (value) {
|
||||
setState(() => _toggleValue = value);
|
||||
toggle.onChanged(value);
|
||||
},
|
||||
),
|
||||
...List.generate(widget.options.length, (index) {
|
||||
final option = widget.options[index];
|
||||
final icon = option.icon;
|
||||
return FocusableListTile(
|
||||
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
|
||||
leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null,
|
||||
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
|
||||
contentPadding: rowPadding,
|
||||
onTap: () async {
|
||||
if (widget.onBeforeClose != null) {
|
||||
final result = await widget.onBeforeClose!(option.value);
|
||||
if (context.mounted) Navigator.pop(context, result);
|
||||
} else {
|
||||
Navigator.pop(context, option.value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/sync_rule_executor.dart';
|
||||
import 'content_utils.dart';
|
||||
import 'dialogs.dart';
|
||||
@@ -67,6 +68,10 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
|
||||
var filter = DownloadFilter.all;
|
||||
int? maxCount;
|
||||
bool keepSynced = false;
|
||||
// Remembered "Include Specials" choice; the toggle is only shown for whole
|
||||
// shows (a single season has no Specials to drop).
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
bool includeSpecials = settings?.read(SettingsService.downloadIncludeSpecials) ?? true;
|
||||
|
||||
if (kind == MediaKind.show || kind == MediaKind.season) {
|
||||
int? customCount;
|
||||
@@ -89,6 +94,14 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
|
||||
context,
|
||||
title: t.downloads.downloadNow,
|
||||
options: options,
|
||||
toggle: kind == MediaKind.show
|
||||
? (
|
||||
label: t.downloads.includeSpecials,
|
||||
icon: Symbols.star_rounded,
|
||||
value: includeSpecials,
|
||||
onChanged: (value) => includeSpecials = value,
|
||||
)
|
||||
: null,
|
||||
onBeforeClose: (value) async {
|
||||
if (value != _DownloadChoice.custom) return value;
|
||||
customCount = await _showEpisodeCountDialog(context);
|
||||
@@ -149,16 +162,23 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
|
||||
targetType: metadata.kind.id.isNotEmpty ? metadata.kind.id : ContentTypes.show,
|
||||
episodeCount: syncCount,
|
||||
mediaIndex: versionConfig.mediaIndex,
|
||||
includeSpecials: includeSpecials,
|
||||
targetMetadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
// Remember the toggle for next time (only shown, and thus meaningful, for shows).
|
||||
if (kind == MediaKind.show) {
|
||||
await settings?.write(SettingsService.downloadIncludeSpecials, includeSpecials);
|
||||
}
|
||||
|
||||
final count = await downloadProvider.queueDownload(
|
||||
metadata,
|
||||
client,
|
||||
versionConfig: versionConfig,
|
||||
filter: filter,
|
||||
maxCount: maxCount,
|
||||
includeSpecials: includeSpecials,
|
||||
);
|
||||
|
||||
return DownloadResult(
|
||||
|
||||
@@ -273,6 +273,10 @@ class FocusableSwitchListTile extends StatefulWidget {
|
||||
/// Visual density for the list tile.
|
||||
final VisualDensity? visualDensity;
|
||||
|
||||
/// Content padding, e.g. to align with sibling rows. Null uses the
|
||||
/// SwitchListTile default.
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
|
||||
const FocusableSwitchListTile({
|
||||
super.key,
|
||||
this.title,
|
||||
@@ -284,6 +288,7 @@ class FocusableSwitchListTile extends StatefulWidget {
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.visualDensity = const VisualDensity(vertical: -3),
|
||||
this.contentPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -325,6 +330,7 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
||||
onChanged: widget.onChanged,
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
contentPadding: widget.contentPadding,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
),
|
||||
|
||||
@@ -40,8 +40,8 @@ class _AppDatabaseTestSuite {
|
||||
// ============================================================
|
||||
|
||||
group('schema', () {
|
||||
test('schemaVersion is 15', () {
|
||||
expect(db.schemaVersion, 15);
|
||||
test('schemaVersion is 16', () {
|
||||
expect(db.schemaVersion, 16);
|
||||
});
|
||||
|
||||
test('all tables are accessible and start empty', () async {
|
||||
@@ -865,6 +865,7 @@ class _AppDatabaseTestSuite {
|
||||
expect(rules.first.enabled, isTrue); // default
|
||||
expect(rules.first.downloadFilter, 'unwatched'); // default
|
||||
expect(rules.first.mediaIndex, 0); // default
|
||||
expect(rules.first.includeSpecials, isTrue); // default
|
||||
expect(rules.first.lastExecutedAt, isNull);
|
||||
});
|
||||
|
||||
@@ -888,6 +889,7 @@ class _AppDatabaseTestSuite {
|
||||
targetType: 'season',
|
||||
episodeCount: 99,
|
||||
downloadFilter: 'all',
|
||||
includeSpecials: false,
|
||||
);
|
||||
|
||||
final rules = await db.getSyncRules();
|
||||
@@ -895,6 +897,7 @@ class _AppDatabaseTestSuite {
|
||||
expect(rules.first.targetType, 'season');
|
||||
expect(rules.first.episodeCount, 99);
|
||||
expect(rules.first.downloadFilter, 'all');
|
||||
expect(rules.first.includeSpecials, isFalse);
|
||||
});
|
||||
|
||||
test('insertSyncRule allows the same server item for different profiles', () async {
|
||||
|
||||
@@ -167,7 +167,13 @@ void main() {
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
|
||||
await p.createSyncRule(serverId: ServerId('srv'), ratingKey: '10', targetType: 'show', episodeCount: 5);
|
||||
await p.createSyncRule(
|
||||
serverId: ServerId('srv'),
|
||||
ratingKey: '10',
|
||||
targetType: 'show',
|
||||
episodeCount: 5,
|
||||
includeSpecials: false,
|
||||
);
|
||||
final ruleKey = p.syncRuleKeyFor(ServerId('srv'), '10');
|
||||
|
||||
expect(p.hasSyncRule(ruleKey), isTrue);
|
||||
@@ -178,10 +184,12 @@ void main() {
|
||||
expect(rule.episodeCount, 5);
|
||||
expect(rule.enabled, isTrue);
|
||||
expect(rule.downloadFilter, 'unwatched'); // default
|
||||
expect(rule.includeSpecials, isFalse);
|
||||
// Database state matches in-memory state.
|
||||
final dbRule = await db.getSyncRule(ruleKey);
|
||||
expect(dbRule, isNotNull);
|
||||
expect(dbRule!.targetType, 'show');
|
||||
expect(dbRule.includeSpecials, isFalse);
|
||||
|
||||
// createSyncRule notifies once on success.
|
||||
expect(notified, 1);
|
||||
|
||||
@@ -299,6 +299,53 @@ void main() {
|
||||
expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty);
|
||||
});
|
||||
|
||||
test('show sync rule respects includeSpecials=false when expanding episodes', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final manager = MultiServerManager();
|
||||
addTearDown(() async {
|
||||
manager.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
final client = _PlayableDescendantsClient([
|
||||
_episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
|
||||
_episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
|
||||
_episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'),
|
||||
]);
|
||||
manager.debugRegisterClientForTesting(client);
|
||||
|
||||
const ruleKey = 'profile-a|plex-machine:show-1';
|
||||
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
|
||||
await db.insertSyncRule(
|
||||
profileId: 'profile-a',
|
||||
serverId: ServerId('plex-machine'),
|
||||
ratingKey: 'show-1',
|
||||
globalKey: ruleKey,
|
||||
targetType: 'show',
|
||||
episodeCount: 0,
|
||||
includeSpecials: false,
|
||||
);
|
||||
|
||||
final queued = <MediaItem>[];
|
||||
final executor = SyncRuleExecutor(database: db);
|
||||
final results = await executor.executeSyncRules(
|
||||
profileId: 'profile-a',
|
||||
serverManager: manager,
|
||||
downloads: const {},
|
||||
metadata: {ruleKey: show},
|
||||
queueSingleDownload: (item, client, {int mediaIndex = 0}) async {
|
||||
queued.add(item);
|
||||
return true;
|
||||
},
|
||||
isOffline: false,
|
||||
force: true,
|
||||
);
|
||||
|
||||
expect(results.single.queuedCount, 2);
|
||||
expect(queued.map((item) => item.id), ['s1e1', 's1e2']);
|
||||
expect(client.fetchPlayableDescendantsCalls, ['show-1']);
|
||||
});
|
||||
|
||||
test('collection sync rule pages through collection API instead of metadata children', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final manager = MultiServerManager();
|
||||
@@ -351,6 +398,55 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) {
|
||||
return MediaItem(
|
||||
id: id,
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: id,
|
||||
parentIndex: parentIndex,
|
||||
index: index,
|
||||
originallyAvailableAt: originallyAvailableAt,
|
||||
);
|
||||
}
|
||||
|
||||
class _PlayableDescendantsClient implements MediaServerClient {
|
||||
_PlayableDescendantsClient(this.leaves);
|
||||
|
||||
final List<MediaItem> leaves;
|
||||
final fetchPlayableDescendantsCalls = <String>[];
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('plex-machine');
|
||||
|
||||
@override
|
||||
String? get serverName => 'Plex';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
||||
|
||||
@override
|
||||
bool get isOfflineMode => false;
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
|
||||
@override
|
||||
Future<MediaItem?> fetchItem(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
fetchPlayableDescendantsCalls.add(parentId);
|
||||
return leaves;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _CollectionPagingClient implements MediaServerClient {
|
||||
bool fetchChildrenCalled = false;
|
||||
final collectionPageCalls = <({int? start, int? size})>[];
|
||||
|
||||
@@ -104,7 +104,36 @@ class _SeasonPagingRecordingClient extends _RecordingClient implements SeasonEpi
|
||||
}
|
||||
}
|
||||
|
||||
class _LeavesClient implements MediaServerClient {
|
||||
_LeavesClient(this.leaves);
|
||||
|
||||
final List<MediaItem> leaves;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => leaves;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('collectEpisodesForShow drops Specials when includeSpecials is false', () async {
|
||||
final client = _LeavesClient([
|
||||
_episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
|
||||
_episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
|
||||
_episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'),
|
||||
]);
|
||||
|
||||
final withoutSpecials = <MediaItem>[];
|
||||
await collectEpisodesForShow(client, 'show-1', unwatchedOnly: false, out: withoutSpecials, includeSpecials: false);
|
||||
expect(withoutSpecials.map((e) => e.id), ['s1e1', 's1e2']);
|
||||
|
||||
// Default keeps Specials, interleaved into aired order.
|
||||
final withSpecials = <MediaItem>[];
|
||||
await collectEpisodesForShow(client, 'show-1', unwatchedOnly: false, out: withSpecials);
|
||||
expect(withSpecials.map((e) => e.id), ['s1e1', 's0e1', 's1e2']);
|
||||
});
|
||||
|
||||
test('defaultPlaybackSeason skips specials when a regular season exists', () {
|
||||
final special = _season('specials', index: 0);
|
||||
final season1 = _season('season-1');
|
||||
|
||||
Reference in New Issue
Block a user