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:
edde746
2026-06-25 23:41:31 +02:00
parent dbe804f8db
commit f04691d321
47 changed files with 420 additions and 46 deletions
+11 -1
View File
@@ -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],
),
+78 -3
View File
@@ -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)))
+1
View File
@@ -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.
+1
View File
@@ -1031,6 +1031,7 @@
"unwatchedOnly": "Само негледани",
"nextNUnwatched": "Следващите ${count} негледани",
"customAmount": "Персонален брой...",
"includeSpecials": "Включи специалните",
"howManyEpisodes": "Колко епизода?",
"itemsQueued": "${count} елемента са добавени в опашката за изтегляне",
"keepSynced": "Поддържай синхронизирано",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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é",
+1
View File
@@ -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",
+1
View File
@@ -1031,6 +1031,7 @@
"unwatchedOnly": "未視聴のみ",
"nextNUnwatched": "次の${count}件の未視聴",
"customAmount": "数を指定...",
"includeSpecials": "スペシャルを含める",
"howManyEpisodes": "何エピソード?",
"itemsQueued": "${count}件をダウンロードキューに追加",
"keepSynced": "同期を維持",
+1
View File
@@ -1031,6 +1031,7 @@
"unwatchedOnly": "시청하지 않은 것만",
"nextNUnwatched": "다음 ${count}개 미시청",
"customAmount": "직접 입력...",
"includeSpecials": "스페셜 포함",
"howManyEpisodes": "몇 개의 에피소드?",
"itemsQueued": "${count}개 항목이 다운로드 대기열에 추가됨",
"keepSynced": "동기화 유지",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1031,6 +1031,7 @@
"unwatchedOnly": "Только непросмотренные",
"nextNUnwatched": "Следующие ${count} непросмотренных",
"customAmount": "Указать количество...",
"includeSpecials": "Включить спецвыпуски",
"howManyEpisodes": "Сколько эпизодов?",
"itemsQueued": "${count} элементов добавлено в очередь загрузки",
"keepSynced": "Синхронизировать",
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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' => 'Дистанционно',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+5 -1
View File
@@ -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)',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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' => 'リモート',
+3 -1
View File
@@ -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' => '리모컨',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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' => 'Пульт',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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' => '遥控',
+1
View File
@@ -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",
+1
View File
@@ -1031,6 +1031,7 @@
"unwatchedOnly": "仅未观看",
"nextNUnwatched": "接下来 ${count} 集未观看",
"customAmount": "自定义数量...",
"includeSpecials": "包含特别篇",
"howManyEpisodes": "下载几集?",
"itemsQueued": "${count} 个项目已加入下载队列",
"keepSynced": "保持同步",
+20 -2
View File
@@ -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));
}
+27 -2
View File
@@ -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.
+6
View File
@@ -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,
+1
View File
@@ -261,6 +261,7 @@ class SyncRuleExecutor {
unwatchedOnly: true,
out: fromServer,
fallback: sourceMetadata,
includeSpecials: rule.includeSpecials,
);
} else {
await collectEpisodesForSeason(
+44 -18
View File
@@ -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);
}
},
);
}),
],
);
}
}
+20
View File
@@ -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(
+6
View File
@@ -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,
),
+5 -2
View File
@@ -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 {
+9 -1
View File
@@ -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})>[];
+29
View File
@@ -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');