@@ -12,12 +12,12 @@ import '../utils/global_key_utils.dart';
|
||||
part 'app_database.g.dart';
|
||||
|
||||
// Simplified database with API cache for offline support
|
||||
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress])
|
||||
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress, SyncRules])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 9; // Added mediaIndex column to DownloadedMedia
|
||||
int get schemaVersion => 11;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -46,6 +46,18 @@ class AppDatabase extends _$AppDatabase {
|
||||
appLogger.w('mediaIndex column may already exist: $e');
|
||||
}
|
||||
}
|
||||
if (from < 10) {
|
||||
appLogger.i('Adding SyncRules table (v10 migration)');
|
||||
await m.createTable(syncRules);
|
||||
}
|
||||
if (from < 11) {
|
||||
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
|
||||
try {
|
||||
await m.addColumn(syncRules, syncRules.enabled);
|
||||
} catch (e) {
|
||||
appLogger.w('enabled column may already exist: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -201,6 +213,61 @@ class AppDatabase extends _$AppDatabase {
|
||||
return delete(offlineWatchProgress).go();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sync Rules Operations
|
||||
// ============================================================
|
||||
|
||||
Future<List<SyncRuleItem>> getSyncRules() {
|
||||
return select(syncRules).get();
|
||||
}
|
||||
|
||||
Future<SyncRuleItem?> getSyncRule(String globalKey) {
|
||||
return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> insertSyncRule({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String targetType,
|
||||
required int episodeCount,
|
||||
int mediaIndex = 0,
|
||||
}) async {
|
||||
await into(syncRules).insertOnConflictUpdate(
|
||||
SyncRulesCompanion.insert(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
targetType: targetType,
|
||||
episodeCount: episodeCount,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
mediaIndex: Value(mediaIndex),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(episodeCount: Value(episodeCount)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(enabled: Value(enabled)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSyncRule(String globalKey) async {
|
||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Downloaded Media Queries for Watch State Sync
|
||||
// ============================================================
|
||||
|
||||
@@ -2511,6 +2511,621 @@ class OfflineWatchProgressCompanion
|
||||
}
|
||||
}
|
||||
|
||||
class $SyncRulesTable extends SyncRules
|
||||
with TableInfo<$SyncRulesTable, SyncRuleItem> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$SyncRulesTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _idMeta = const VerificationMeta('id');
|
||||
@override
|
||||
late final GeneratedColumn<int> id = GeneratedColumn<int>(
|
||||
'id',
|
||||
aliasedName,
|
||||
false,
|
||||
hasAutoIncrement: true,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'PRIMARY KEY AUTOINCREMENT',
|
||||
),
|
||||
);
|
||||
static const VerificationMeta _serverIdMeta = const VerificationMeta(
|
||||
'serverId',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> serverId = GeneratedColumn<String>(
|
||||
'server_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _ratingKeyMeta = const VerificationMeta(
|
||||
'ratingKey',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> ratingKey = GeneratedColumn<String>(
|
||||
'rating_key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _globalKeyMeta = const VerificationMeta(
|
||||
'globalKey',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> globalKey = GeneratedColumn<String>(
|
||||
'global_key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'),
|
||||
);
|
||||
static const VerificationMeta _targetTypeMeta = const VerificationMeta(
|
||||
'targetType',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> targetType = GeneratedColumn<String>(
|
||||
'target_type',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _episodeCountMeta = const VerificationMeta(
|
||||
'episodeCount',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> episodeCount = GeneratedColumn<int>(
|
||||
'episode_count',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _enabledMeta = const VerificationMeta(
|
||||
'enabled',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> enabled = GeneratedColumn<bool>(
|
||||
'enabled',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("enabled" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(true),
|
||||
);
|
||||
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
||||
'createdAt',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
|
||||
'created_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _lastExecutedAtMeta = const VerificationMeta(
|
||||
'lastExecutedAt',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> lastExecutedAt = GeneratedColumn<int>(
|
||||
'last_executed_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _mediaIndexMeta = const VerificationMeta(
|
||||
'mediaIndex',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> mediaIndex = GeneratedColumn<int>(
|
||||
'media_index',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant(0),
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
serverId,
|
||||
ratingKey,
|
||||
globalKey,
|
||||
targetType,
|
||||
episodeCount,
|
||||
enabled,
|
||||
createdAt,
|
||||
lastExecutedAt,
|
||||
mediaIndex,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'sync_rules';
|
||||
@override
|
||||
VerificationContext validateIntegrity(
|
||||
Insertable<SyncRuleItem> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('id')) {
|
||||
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
||||
}
|
||||
if (data.containsKey('server_id')) {
|
||||
context.handle(
|
||||
_serverIdMeta,
|
||||
serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_serverIdMeta);
|
||||
}
|
||||
if (data.containsKey('rating_key')) {
|
||||
context.handle(
|
||||
_ratingKeyMeta,
|
||||
ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_ratingKeyMeta);
|
||||
}
|
||||
if (data.containsKey('global_key')) {
|
||||
context.handle(
|
||||
_globalKeyMeta,
|
||||
globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_globalKeyMeta);
|
||||
}
|
||||
if (data.containsKey('target_type')) {
|
||||
context.handle(
|
||||
_targetTypeMeta,
|
||||
targetType.isAcceptableOrUnknown(data['target_type']!, _targetTypeMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_targetTypeMeta);
|
||||
}
|
||||
if (data.containsKey('episode_count')) {
|
||||
context.handle(
|
||||
_episodeCountMeta,
|
||||
episodeCount.isAcceptableOrUnknown(
|
||||
data['episode_count']!,
|
||||
_episodeCountMeta,
|
||||
),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_episodeCountMeta);
|
||||
}
|
||||
if (data.containsKey('enabled')) {
|
||||
context.handle(
|
||||
_enabledMeta,
|
||||
enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('created_at')) {
|
||||
context.handle(
|
||||
_createdAtMeta,
|
||||
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_createdAtMeta);
|
||||
}
|
||||
if (data.containsKey('last_executed_at')) {
|
||||
context.handle(
|
||||
_lastExecutedAtMeta,
|
||||
lastExecutedAt.isAcceptableOrUnknown(
|
||||
data['last_executed_at']!,
|
||||
_lastExecutedAtMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('media_index')) {
|
||||
context.handle(
|
||||
_mediaIndexMeta,
|
||||
mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {id};
|
||||
@override
|
||||
SyncRuleItem map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return SyncRuleItem(
|
||||
id: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}id'],
|
||||
)!,
|
||||
serverId: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}server_id'],
|
||||
)!,
|
||||
ratingKey: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}rating_key'],
|
||||
)!,
|
||||
globalKey: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}global_key'],
|
||||
)!,
|
||||
targetType: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}target_type'],
|
||||
)!,
|
||||
episodeCount: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}episode_count'],
|
||||
)!,
|
||||
enabled: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}enabled'],
|
||||
)!,
|
||||
createdAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}created_at'],
|
||||
)!,
|
||||
lastExecutedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}last_executed_at'],
|
||||
),
|
||||
mediaIndex: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}media_index'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$SyncRulesTable createAlias(String alias) {
|
||||
return $SyncRulesTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncRuleItem extends DataClass implements Insertable<SyncRuleItem> {
|
||||
final int id;
|
||||
final String serverId;
|
||||
final String ratingKey;
|
||||
final String globalKey;
|
||||
final String targetType;
|
||||
final int episodeCount;
|
||||
final bool enabled;
|
||||
final int createdAt;
|
||||
final int? lastExecutedAt;
|
||||
final int mediaIndex;
|
||||
const SyncRuleItem({
|
||||
required this.id,
|
||||
required this.serverId,
|
||||
required this.ratingKey,
|
||||
required this.globalKey,
|
||||
required this.targetType,
|
||||
required this.episodeCount,
|
||||
required this.enabled,
|
||||
required this.createdAt,
|
||||
this.lastExecutedAt,
|
||||
required this.mediaIndex,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['id'] = Variable<int>(id);
|
||||
map['server_id'] = Variable<String>(serverId);
|
||||
map['rating_key'] = Variable<String>(ratingKey);
|
||||
map['global_key'] = Variable<String>(globalKey);
|
||||
map['target_type'] = Variable<String>(targetType);
|
||||
map['episode_count'] = Variable<int>(episodeCount);
|
||||
map['enabled'] = Variable<bool>(enabled);
|
||||
map['created_at'] = Variable<int>(createdAt);
|
||||
if (!nullToAbsent || lastExecutedAt != null) {
|
||||
map['last_executed_at'] = Variable<int>(lastExecutedAt);
|
||||
}
|
||||
map['media_index'] = Variable<int>(mediaIndex);
|
||||
return map;
|
||||
}
|
||||
|
||||
SyncRulesCompanion toCompanion(bool nullToAbsent) {
|
||||
return SyncRulesCompanion(
|
||||
id: Value(id),
|
||||
serverId: Value(serverId),
|
||||
ratingKey: Value(ratingKey),
|
||||
globalKey: Value(globalKey),
|
||||
targetType: Value(targetType),
|
||||
episodeCount: Value(episodeCount),
|
||||
enabled: Value(enabled),
|
||||
createdAt: Value(createdAt),
|
||||
lastExecutedAt: lastExecutedAt == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(lastExecutedAt),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
);
|
||||
}
|
||||
|
||||
factory SyncRuleItem.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return SyncRuleItem(
|
||||
id: serializer.fromJson<int>(json['id']),
|
||||
serverId: serializer.fromJson<String>(json['serverId']),
|
||||
ratingKey: serializer.fromJson<String>(json['ratingKey']),
|
||||
globalKey: serializer.fromJson<String>(json['globalKey']),
|
||||
targetType: serializer.fromJson<String>(json['targetType']),
|
||||
episodeCount: serializer.fromJson<int>(json['episodeCount']),
|
||||
enabled: serializer.fromJson<bool>(json['enabled']),
|
||||
createdAt: serializer.fromJson<int>(json['createdAt']),
|
||||
lastExecutedAt: serializer.fromJson<int?>(json['lastExecutedAt']),
|
||||
mediaIndex: serializer.fromJson<int>(json['mediaIndex']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'id': serializer.toJson<int>(id),
|
||||
'serverId': serializer.toJson<String>(serverId),
|
||||
'ratingKey': serializer.toJson<String>(ratingKey),
|
||||
'globalKey': serializer.toJson<String>(globalKey),
|
||||
'targetType': serializer.toJson<String>(targetType),
|
||||
'episodeCount': serializer.toJson<int>(episodeCount),
|
||||
'enabled': serializer.toJson<bool>(enabled),
|
||||
'createdAt': serializer.toJson<int>(createdAt),
|
||||
'lastExecutedAt': serializer.toJson<int?>(lastExecutedAt),
|
||||
'mediaIndex': serializer.toJson<int>(mediaIndex),
|
||||
};
|
||||
}
|
||||
|
||||
SyncRuleItem copyWith({
|
||||
int? id,
|
||||
String? serverId,
|
||||
String? ratingKey,
|
||||
String? globalKey,
|
||||
String? targetType,
|
||||
int? episodeCount,
|
||||
bool? enabled,
|
||||
int? createdAt,
|
||||
Value<int?> lastExecutedAt = const Value.absent(),
|
||||
int? mediaIndex,
|
||||
}) => SyncRuleItem(
|
||||
id: id ?? this.id,
|
||||
serverId: serverId ?? this.serverId,
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
globalKey: globalKey ?? this.globalKey,
|
||||
targetType: targetType ?? this.targetType,
|
||||
episodeCount: episodeCount ?? this.episodeCount,
|
||||
enabled: enabled ?? this.enabled,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastExecutedAt: lastExecutedAt.present
|
||||
? lastExecutedAt.value
|
||||
: this.lastExecutedAt,
|
||||
mediaIndex: mediaIndex ?? this.mediaIndex,
|
||||
);
|
||||
SyncRuleItem copyWithCompanion(SyncRulesCompanion data) {
|
||||
return SyncRuleItem(
|
||||
id: data.id.present ? data.id.value : this.id,
|
||||
serverId: data.serverId.present ? data.serverId.value : this.serverId,
|
||||
ratingKey: data.ratingKey.present ? data.ratingKey.value : this.ratingKey,
|
||||
globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey,
|
||||
targetType: data.targetType.present
|
||||
? data.targetType.value
|
||||
: this.targetType,
|
||||
episodeCount: data.episodeCount.present
|
||||
? data.episodeCount.value
|
||||
: this.episodeCount,
|
||||
enabled: data.enabled.present ? data.enabled.value : this.enabled,
|
||||
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||
lastExecutedAt: data.lastExecutedAt.present
|
||||
? data.lastExecutedAt.value
|
||||
: this.lastExecutedAt,
|
||||
mediaIndex: data.mediaIndex.present
|
||||
? data.mediaIndex.value
|
||||
: this.mediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('SyncRuleItem(')
|
||||
..write('id: $id, ')
|
||||
..write('serverId: $serverId, ')
|
||||
..write('ratingKey: $ratingKey, ')
|
||||
..write('globalKey: $globalKey, ')
|
||||
..write('targetType: $targetType, ')
|
||||
..write('episodeCount: $episodeCount, ')
|
||||
..write('enabled: $enabled, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('lastExecutedAt: $lastExecutedAt, ')
|
||||
..write('mediaIndex: $mediaIndex')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
serverId,
|
||||
ratingKey,
|
||||
globalKey,
|
||||
targetType,
|
||||
episodeCount,
|
||||
enabled,
|
||||
createdAt,
|
||||
lastExecutedAt,
|
||||
mediaIndex,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is SyncRuleItem &&
|
||||
other.id == this.id &&
|
||||
other.serverId == this.serverId &&
|
||||
other.ratingKey == this.ratingKey &&
|
||||
other.globalKey == this.globalKey &&
|
||||
other.targetType == this.targetType &&
|
||||
other.episodeCount == this.episodeCount &&
|
||||
other.enabled == this.enabled &&
|
||||
other.createdAt == this.createdAt &&
|
||||
other.lastExecutedAt == this.lastExecutedAt &&
|
||||
other.mediaIndex == this.mediaIndex);
|
||||
}
|
||||
|
||||
class SyncRulesCompanion extends UpdateCompanion<SyncRuleItem> {
|
||||
final Value<int> id;
|
||||
final Value<String> serverId;
|
||||
final Value<String> ratingKey;
|
||||
final Value<String> globalKey;
|
||||
final Value<String> targetType;
|
||||
final Value<int> episodeCount;
|
||||
final Value<bool> enabled;
|
||||
final Value<int> createdAt;
|
||||
final Value<int?> lastExecutedAt;
|
||||
final Value<int> mediaIndex;
|
||||
const SyncRulesCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.serverId = const Value.absent(),
|
||||
this.ratingKey = const Value.absent(),
|
||||
this.globalKey = const Value.absent(),
|
||||
this.targetType = const Value.absent(),
|
||||
this.episodeCount = const Value.absent(),
|
||||
this.enabled = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
this.lastExecutedAt = const Value.absent(),
|
||||
this.mediaIndex = const Value.absent(),
|
||||
});
|
||||
SyncRulesCompanion.insert({
|
||||
this.id = const Value.absent(),
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String targetType,
|
||||
required int episodeCount,
|
||||
this.enabled = const Value.absent(),
|
||||
required int createdAt,
|
||||
this.lastExecutedAt = const Value.absent(),
|
||||
this.mediaIndex = const Value.absent(),
|
||||
}) : serverId = Value(serverId),
|
||||
ratingKey = Value(ratingKey),
|
||||
globalKey = Value(globalKey),
|
||||
targetType = Value(targetType),
|
||||
episodeCount = Value(episodeCount),
|
||||
createdAt = Value(createdAt);
|
||||
static Insertable<SyncRuleItem> custom({
|
||||
Expression<int>? id,
|
||||
Expression<String>? serverId,
|
||||
Expression<String>? ratingKey,
|
||||
Expression<String>? globalKey,
|
||||
Expression<String>? targetType,
|
||||
Expression<int>? episodeCount,
|
||||
Expression<bool>? enabled,
|
||||
Expression<int>? createdAt,
|
||||
Expression<int>? lastExecutedAt,
|
||||
Expression<int>? mediaIndex,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
if (serverId != null) 'server_id': serverId,
|
||||
if (ratingKey != null) 'rating_key': ratingKey,
|
||||
if (globalKey != null) 'global_key': globalKey,
|
||||
if (targetType != null) 'target_type': targetType,
|
||||
if (episodeCount != null) 'episode_count': episodeCount,
|
||||
if (enabled != null) 'enabled': enabled,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
if (lastExecutedAt != null) 'last_executed_at': lastExecutedAt,
|
||||
if (mediaIndex != null) 'media_index': mediaIndex,
|
||||
});
|
||||
}
|
||||
|
||||
SyncRulesCompanion copyWith({
|
||||
Value<int>? id,
|
||||
Value<String>? serverId,
|
||||
Value<String>? ratingKey,
|
||||
Value<String>? globalKey,
|
||||
Value<String>? targetType,
|
||||
Value<int>? episodeCount,
|
||||
Value<bool>? enabled,
|
||||
Value<int>? createdAt,
|
||||
Value<int?>? lastExecutedAt,
|
||||
Value<int>? mediaIndex,
|
||||
}) {
|
||||
return SyncRulesCompanion(
|
||||
id: id ?? this.id,
|
||||
serverId: serverId ?? this.serverId,
|
||||
ratingKey: ratingKey ?? this.ratingKey,
|
||||
globalKey: globalKey ?? this.globalKey,
|
||||
targetType: targetType ?? this.targetType,
|
||||
episodeCount: episodeCount ?? this.episodeCount,
|
||||
enabled: enabled ?? this.enabled,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastExecutedAt: lastExecutedAt ?? this.lastExecutedAt,
|
||||
mediaIndex: mediaIndex ?? this.mediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (id.present) {
|
||||
map['id'] = Variable<int>(id.value);
|
||||
}
|
||||
if (serverId.present) {
|
||||
map['server_id'] = Variable<String>(serverId.value);
|
||||
}
|
||||
if (ratingKey.present) {
|
||||
map['rating_key'] = Variable<String>(ratingKey.value);
|
||||
}
|
||||
if (globalKey.present) {
|
||||
map['global_key'] = Variable<String>(globalKey.value);
|
||||
}
|
||||
if (targetType.present) {
|
||||
map['target_type'] = Variable<String>(targetType.value);
|
||||
}
|
||||
if (episodeCount.present) {
|
||||
map['episode_count'] = Variable<int>(episodeCount.value);
|
||||
}
|
||||
if (enabled.present) {
|
||||
map['enabled'] = Variable<bool>(enabled.value);
|
||||
}
|
||||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<int>(createdAt.value);
|
||||
}
|
||||
if (lastExecutedAt.present) {
|
||||
map['last_executed_at'] = Variable<int>(lastExecutedAt.value);
|
||||
}
|
||||
if (mediaIndex.present) {
|
||||
map['media_index'] = Variable<int>(mediaIndex.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('SyncRulesCompanion(')
|
||||
..write('id: $id, ')
|
||||
..write('serverId: $serverId, ')
|
||||
..write('ratingKey: $ratingKey, ')
|
||||
..write('globalKey: $globalKey, ')
|
||||
..write('targetType: $targetType, ')
|
||||
..write('episodeCount: $episodeCount, ')
|
||||
..write('enabled: $enabled, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('lastExecutedAt: $lastExecutedAt, ')
|
||||
..write('mediaIndex: $mediaIndex')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
_$AppDatabase(QueryExecutor e) : super(e);
|
||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||
@@ -2521,6 +3136,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
late final $ApiCacheTable apiCache = $ApiCacheTable(this);
|
||||
late final $OfflineWatchProgressTable offlineWatchProgress =
|
||||
$OfflineWatchProgressTable(this);
|
||||
late final $SyncRulesTable syncRules = $SyncRulesTable(this);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@@ -2530,6 +3146,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
downloadQueue,
|
||||
apiCache,
|
||||
offlineWatchProgress,
|
||||
syncRules,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3757,6 +4374,303 @@ typedef $$OfflineWatchProgressTableProcessedTableManager =
|
||||
OfflineWatchProgressItem,
|
||||
PrefetchHooks Function()
|
||||
>;
|
||||
typedef $$SyncRulesTableCreateCompanionBuilder =
|
||||
SyncRulesCompanion Function({
|
||||
Value<int> id,
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String targetType,
|
||||
required int episodeCount,
|
||||
Value<bool> enabled,
|
||||
required int createdAt,
|
||||
Value<int?> lastExecutedAt,
|
||||
Value<int> mediaIndex,
|
||||
});
|
||||
typedef $$SyncRulesTableUpdateCompanionBuilder =
|
||||
SyncRulesCompanion Function({
|
||||
Value<int> id,
|
||||
Value<String> serverId,
|
||||
Value<String> ratingKey,
|
||||
Value<String> globalKey,
|
||||
Value<String> targetType,
|
||||
Value<int> episodeCount,
|
||||
Value<bool> enabled,
|
||||
Value<int> createdAt,
|
||||
Value<int?> lastExecutedAt,
|
||||
Value<int> mediaIndex,
|
||||
});
|
||||
|
||||
class $$SyncRulesTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $SyncRulesTable> {
|
||||
$$SyncRulesTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get serverId => $composableBuilder(
|
||||
column: $table.serverId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get ratingKey => $composableBuilder(
|
||||
column: $table.ratingKey,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get globalKey => $composableBuilder(
|
||||
column: $table.globalKey,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get targetType => $composableBuilder(
|
||||
column: $table.targetType,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get episodeCount => $composableBuilder(
|
||||
column: $table.episodeCount,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get enabled => $composableBuilder(
|
||||
column: $table.enabled,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get lastExecutedAt => $composableBuilder(
|
||||
column: $table.lastExecutedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get mediaIndex => $composableBuilder(
|
||||
column: $table.mediaIndex,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SyncRulesTableOrderingComposer
|
||||
extends Composer<_$AppDatabase, $SyncRulesTable> {
|
||||
$$SyncRulesTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<int> get id => $composableBuilder(
|
||||
column: $table.id,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get serverId => $composableBuilder(
|
||||
column: $table.serverId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get ratingKey => $composableBuilder(
|
||||
column: $table.ratingKey,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get globalKey => $composableBuilder(
|
||||
column: $table.globalKey,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get targetType => $composableBuilder(
|
||||
column: $table.targetType,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get episodeCount => $composableBuilder(
|
||||
column: $table.episodeCount,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get enabled => $composableBuilder(
|
||||
column: $table.enabled,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get createdAt => $composableBuilder(
|
||||
column: $table.createdAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get lastExecutedAt => $composableBuilder(
|
||||
column: $table.lastExecutedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get mediaIndex => $composableBuilder(
|
||||
column: $table.mediaIndex,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SyncRulesTableAnnotationComposer
|
||||
extends Composer<_$AppDatabase, $SyncRulesTable> {
|
||||
$$SyncRulesTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<int> get id =>
|
||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get serverId =>
|
||||
$composableBuilder(column: $table.serverId, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get ratingKey =>
|
||||
$composableBuilder(column: $table.ratingKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get globalKey =>
|
||||
$composableBuilder(column: $table.globalKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get targetType => $composableBuilder(
|
||||
column: $table.targetType,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get episodeCount => $composableBuilder(
|
||||
column: $table.episodeCount,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get enabled =>
|
||||
$composableBuilder(column: $table.enabled, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> get createdAt =>
|
||||
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> get lastExecutedAt => $composableBuilder(
|
||||
column: $table.lastExecutedAt,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get mediaIndex => $composableBuilder(
|
||||
column: $table.mediaIndex,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$SyncRulesTableTableManager
|
||||
extends
|
||||
RootTableManager<
|
||||
_$AppDatabase,
|
||||
$SyncRulesTable,
|
||||
SyncRuleItem,
|
||||
$$SyncRulesTableFilterComposer,
|
||||
$$SyncRulesTableOrderingComposer,
|
||||
$$SyncRulesTableAnnotationComposer,
|
||||
$$SyncRulesTableCreateCompanionBuilder,
|
||||
$$SyncRulesTableUpdateCompanionBuilder,
|
||||
(
|
||||
SyncRuleItem,
|
||||
BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>,
|
||||
),
|
||||
SyncRuleItem,
|
||||
PrefetchHooks Function()
|
||||
> {
|
||||
$$SyncRulesTableTableManager(_$AppDatabase db, $SyncRulesTable table)
|
||||
: super(
|
||||
TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$SyncRulesTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$SyncRulesTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$SyncRulesTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
Value<String> serverId = const Value.absent(),
|
||||
Value<String> ratingKey = const Value.absent(),
|
||||
Value<String> globalKey = const Value.absent(),
|
||||
Value<String> targetType = const Value.absent(),
|
||||
Value<int> episodeCount = const Value.absent(),
|
||||
Value<bool> enabled = const Value.absent(),
|
||||
Value<int> createdAt = const Value.absent(),
|
||||
Value<int?> lastExecutedAt = const Value.absent(),
|
||||
Value<int> mediaIndex = const Value.absent(),
|
||||
}) => SyncRulesCompanion(
|
||||
id: id,
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
targetType: targetType,
|
||||
episodeCount: episodeCount,
|
||||
enabled: enabled,
|
||||
createdAt: createdAt,
|
||||
lastExecutedAt: lastExecutedAt,
|
||||
mediaIndex: mediaIndex,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
Value<int> id = const Value.absent(),
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String targetType,
|
||||
required int episodeCount,
|
||||
Value<bool> enabled = const Value.absent(),
|
||||
required int createdAt,
|
||||
Value<int?> lastExecutedAt = const Value.absent(),
|
||||
Value<int> mediaIndex = const Value.absent(),
|
||||
}) => SyncRulesCompanion.insert(
|
||||
id: id,
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
targetType: targetType,
|
||||
episodeCount: episodeCount,
|
||||
enabled: enabled,
|
||||
createdAt: createdAt,
|
||||
lastExecutedAt: lastExecutedAt,
|
||||
mediaIndex: mediaIndex,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$SyncRulesTableProcessedTableManager =
|
||||
ProcessedTableManager<
|
||||
_$AppDatabase,
|
||||
$SyncRulesTable,
|
||||
SyncRuleItem,
|
||||
$$SyncRulesTableFilterComposer,
|
||||
$$SyncRulesTableOrderingComposer,
|
||||
$$SyncRulesTableAnnotationComposer,
|
||||
$$SyncRulesTableCreateCompanionBuilder,
|
||||
$$SyncRulesTableUpdateCompanionBuilder,
|
||||
(
|
||||
SyncRuleItem,
|
||||
BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>,
|
||||
),
|
||||
SyncRuleItem,
|
||||
PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $AppDatabaseManager {
|
||||
final _$AppDatabase _db;
|
||||
@@ -3769,4 +4683,6 @@ class $AppDatabaseManager {
|
||||
$$ApiCacheTableTableManager(_db, _db.apiCache);
|
||||
$$OfflineWatchProgressTableTableManager get offlineWatchProgress =>
|
||||
$$OfflineWatchProgressTableTableManager(_db, _db.offlineWatchProgress);
|
||||
$$SyncRulesTableTableManager get syncRules =>
|
||||
$$SyncRulesTableTableManager(_db, _db.syncRules);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,24 @@ class DownloadedMedia extends Table {
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
}
|
||||
|
||||
/// Persistent sync rules for auto-downloading unwatched episodes.
|
||||
///
|
||||
/// Each rule keeps a rolling window of N unwatched episodes for a show/season.
|
||||
/// When watched episodes are removed, new unwatched ones are queued.
|
||||
@DataClassName('SyncRuleItem')
|
||||
class SyncRules extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get serverId => text()();
|
||||
TextColumn get ratingKey => text()();
|
||||
TextColumn get globalKey => text().unique()();
|
||||
TextColumn get targetType => text()(); // 'show' or 'season'
|
||||
IntColumn get episodeCount => integer()();
|
||||
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
|
||||
IntColumn get createdAt => integer()();
|
||||
IntColumn get lastExecutedAt => integer().nullable()();
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
}
|
||||
|
||||
/// Queue for offline watch progress and manual watch actions.
|
||||
///
|
||||
/// Stores watch progress updates and manual watch/unwatch actions
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Næste ${count} usete",
|
||||
"customAmount": "Angiv antal...",
|
||||
"howManyEpisodes": "Hvor mange episoder?",
|
||||
"itemsQueued": "${count} elementer sat i kø til download"
|
||||
"itemsQueued": "${count} elementer sat i kø til download",
|
||||
"keepSynced": "Hold synkroniseret",
|
||||
"downloadOnce": "Download én gang",
|
||||
"keepNUnwatched": "Behold ${count} usete",
|
||||
"editSyncRule": "Rediger synkroniseringsregel",
|
||||
"removeSyncRule": "Fjern synkroniseringsregel",
|
||||
"removeSyncRuleConfirm": "Stop synkronisering af \"${title}\"? Downloadede episoder beholdes.",
|
||||
"syncRuleCreated": "Synkroniseringsregel oprettet — beholder ${count} usete episoder",
|
||||
"syncRuleUpdated": "Synkroniseringsregel opdateret",
|
||||
"syncRuleRemoved": "Synkroniseringsregel fjernet",
|
||||
"syncedNewEpisodes": "Synkroniserede ${count} nye episoder for ${title}",
|
||||
"activeSyncRules": "Synkroniseringsregler",
|
||||
"noSyncRules": "Ingen synkroniseringsregler",
|
||||
"lastSynced": "Sidst synkroniseret ${time}",
|
||||
"manageSyncRule": "Administrer synkronisering",
|
||||
"editEpisodeCount": "Antal episoder"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shadere",
|
||||
|
||||
+16
-1
@@ -647,7 +647,22 @@
|
||||
"nextNUnwatched": "Nächste ${count} ungesehene",
|
||||
"customAmount": "Eigene Anzahl...",
|
||||
"howManyEpisodes": "Wie viele Episoden?",
|
||||
"itemsQueued": "${count} Elemente zum Download eingereiht"
|
||||
"itemsQueued": "${count} Elemente zum Download eingereiht",
|
||||
"keepSynced": "Synchronisiert halten",
|
||||
"downloadOnce": "Einmal herunterladen",
|
||||
"keepNUnwatched": "${count} ungesehene behalten",
|
||||
"editSyncRule": "Sync-Regel bearbeiten",
|
||||
"removeSyncRule": "Sync-Regel entfernen",
|
||||
"removeSyncRuleConfirm": "Synchronisierung von \"${title}\" beenden? Heruntergeladene Episoden werden behalten.",
|
||||
"syncRuleCreated": "Sync-Regel erstellt — ${count} ungesehene Episoden werden behalten",
|
||||
"syncRuleUpdated": "Sync-Regel aktualisiert",
|
||||
"syncRuleRemoved": "Sync-Regel entfernt",
|
||||
"syncedNewEpisodes": "${count} neue Episoden für ${title} synchronisiert",
|
||||
"activeSyncRules": "Sync-Regeln",
|
||||
"noSyncRules": "Keine Sync-Regeln",
|
||||
"lastSynced": "Zuletzt synchronisiert ${time}",
|
||||
"manageSyncRule": "Synchronisierung verwalten",
|
||||
"editEpisodeCount": "Episodenanzahl"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Wiedergabelisten",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Next ${count} unwatched",
|
||||
"customAmount": "Custom amount...",
|
||||
"howManyEpisodes": "How many episodes?",
|
||||
"itemsQueued": "${count} items queued for download"
|
||||
"itemsQueued": "${count} items queued for download",
|
||||
"keepSynced": "Keep synced",
|
||||
"downloadOnce": "Download once",
|
||||
"keepNUnwatched": "Keep ${count} unwatched",
|
||||
"editSyncRule": "Edit sync rule",
|
||||
"removeSyncRule": "Remove sync rule",
|
||||
"removeSyncRuleConfirm": "Stop syncing \"${title}\"? Downloaded episodes will be kept.",
|
||||
"syncRuleCreated": "Sync rule created — keeping ${count} unwatched episodes",
|
||||
"syncRuleUpdated": "Sync rule updated",
|
||||
"syncRuleRemoved": "Sync rule removed",
|
||||
"syncedNewEpisodes": "Synced ${count} new episodes for ${title}",
|
||||
"activeSyncRules": "Sync rules",
|
||||
"noSyncRules": "No sync rules",
|
||||
"lastSynced": "Last synced ${time}",
|
||||
"manageSyncRule": "Manage sync",
|
||||
"editEpisodeCount": "Episode count"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Próximos ${count} no vistos",
|
||||
"customAmount": "Cantidad personalizada...",
|
||||
"howManyEpisodes": "¿Cuántos episodios?",
|
||||
"itemsQueued": "${count} elementos en cola de descarga"
|
||||
"itemsQueued": "${count} elementos en cola de descarga",
|
||||
"keepSynced": "Mantener sincronizado",
|
||||
"downloadOnce": "Descargar una vez",
|
||||
"keepNUnwatched": "Mantener ${count} sin ver",
|
||||
"editSyncRule": "Editar regla de sincronización",
|
||||
"removeSyncRule": "Eliminar regla de sincronización",
|
||||
"removeSyncRuleConfirm": "¿Dejar de sincronizar \"${title}\"? Los episodios descargados se conservarán.",
|
||||
"syncRuleCreated": "Regla de sincronización creada — conservando ${count} episodios sin ver",
|
||||
"syncRuleUpdated": "Regla de sincronización actualizada",
|
||||
"syncRuleRemoved": "Regla de sincronización eliminada",
|
||||
"syncedNewEpisodes": "${count} nuevos episodios sincronizados para ${title}",
|
||||
"activeSyncRules": "Reglas de sincronización",
|
||||
"noSyncRules": "Sin reglas de sincronización",
|
||||
"lastSynced": "Última sincronización ${time}",
|
||||
"manageSyncRule": "Gestionar sincronización",
|
||||
"editEpisodeCount": "Número de episodios"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "${count} prochains non vus",
|
||||
"customAmount": "Quantité personnalisée...",
|
||||
"howManyEpisodes": "Combien d'épisodes ?",
|
||||
"itemsQueued": "${count} éléments mis en file d'attente"
|
||||
"itemsQueued": "${count} éléments mis en file d'attente",
|
||||
"keepSynced": "Garder synchronisé",
|
||||
"downloadOnce": "Télécharger une fois",
|
||||
"keepNUnwatched": "Garder ${count} non vus",
|
||||
"editSyncRule": "Modifier la règle de synchronisation",
|
||||
"removeSyncRule": "Supprimer la règle de synchronisation",
|
||||
"removeSyncRuleConfirm": "Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.",
|
||||
"syncRuleCreated": "Règle de synchronisation créée — ${count} épisodes non vus conservés",
|
||||
"syncRuleUpdated": "Règle de synchronisation mise à jour",
|
||||
"syncRuleRemoved": "Règle de synchronisation supprimée",
|
||||
"syncedNewEpisodes": "${count} nouveaux épisodes synchronisés pour ${title}",
|
||||
"activeSyncRules": "Règles de synchronisation",
|
||||
"noSyncRules": "Aucune règle de synchronisation",
|
||||
"lastSynced": "Dernière synchronisation ${time}",
|
||||
"manageSyncRule": "Gérer la synchronisation",
|
||||
"editEpisodeCount": "Nombre d’épisodes"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
+16
-1
@@ -647,7 +647,22 @@
|
||||
"nextNUnwatched": "Prossimi ${count} non visti",
|
||||
"customAmount": "Quantità personalizzata...",
|
||||
"howManyEpisodes": "Quanti episodi?",
|
||||
"itemsQueued": "${count} elementi in coda per il download"
|
||||
"itemsQueued": "${count} elementi in coda per il download",
|
||||
"keepSynced": "Mantieni sincronizzato",
|
||||
"downloadOnce": "Scarica una volta",
|
||||
"keepNUnwatched": "Mantieni ${count} non visti",
|
||||
"editSyncRule": "Modifica regola di sincronizzazione",
|
||||
"removeSyncRule": "Rimuovi regola di sincronizzazione",
|
||||
"removeSyncRuleConfirm": "Interrompere la sincronizzazione di \"${title}\"? Gli episodi scaricati verranno mantenuti.",
|
||||
"syncRuleCreated": "Regola di sincronizzazione creata — ${count} episodi non visti mantenuti",
|
||||
"syncRuleUpdated": "Regola di sincronizzazione aggiornata",
|
||||
"syncRuleRemoved": "Regola di sincronizzazione rimossa",
|
||||
"syncedNewEpisodes": "${count} nuovi episodi sincronizzati per ${title}",
|
||||
"activeSyncRules": "Regole di sincronizzazione",
|
||||
"noSyncRules": "Nessuna regola di sincronizzazione",
|
||||
"lastSynced": "Ultima sincronizzazione ${time}",
|
||||
"manageSyncRule": "Gestisci sincronizzazione",
|
||||
"editEpisodeCount": "Numero di episodi"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Playlist",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "次の${count}件の未視聴",
|
||||
"customAmount": "数を指定...",
|
||||
"howManyEpisodes": "何エピソード?",
|
||||
"itemsQueued": "${count}件をダウンロードキューに追加"
|
||||
"itemsQueued": "${count}件をダウンロードキューに追加",
|
||||
"keepSynced": "同期を維持",
|
||||
"downloadOnce": "一度だけダウンロード",
|
||||
"keepNUnwatched": "未視聴を${count}件保持",
|
||||
"editSyncRule": "同期ルールを編集",
|
||||
"removeSyncRule": "同期ルールを削除",
|
||||
"removeSyncRuleConfirm": "「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。",
|
||||
"syncRuleCreated": "同期ルールを作成しました — 未視聴のエピソードを${count}件保持",
|
||||
"syncRuleUpdated": "同期ルールを更新しました",
|
||||
"syncRuleRemoved": "同期ルールを削除しました",
|
||||
"syncedNewEpisodes": "${title}の新しいエピソードを${count}件同期しました",
|
||||
"activeSyncRules": "同期ルール",
|
||||
"noSyncRules": "同期ルールなし",
|
||||
"lastSynced": "最終同期 ${time}",
|
||||
"manageSyncRule": "同期を管理",
|
||||
"editEpisodeCount": "エピソード数"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "シェーダー",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "다음 ${count}개 미시청",
|
||||
"customAmount": "직접 입력...",
|
||||
"howManyEpisodes": "몇 개의 에피소드?",
|
||||
"itemsQueued": "${count}개 항목이 다운로드 대기열에 추가됨"
|
||||
"itemsQueued": "${count}개 항목이 다운로드 대기열에 추가됨",
|
||||
"keepSynced": "동기화 유지",
|
||||
"downloadOnce": "한 번만 다운로드",
|
||||
"keepNUnwatched": "미시청 ${count}개 유지",
|
||||
"editSyncRule": "동기화 규칙 편집",
|
||||
"removeSyncRule": "동기화 규칙 제거",
|
||||
"removeSyncRuleConfirm": "\"${title}\" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.",
|
||||
"syncRuleCreated": "동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지",
|
||||
"syncRuleUpdated": "동기화 규칙 업데이트됨",
|
||||
"syncRuleRemoved": "동기화 규칙 제거됨",
|
||||
"syncedNewEpisodes": "${title}의 새 에피소드 ${count}개 동기화됨",
|
||||
"activeSyncRules": "동기화 규칙",
|
||||
"noSyncRules": "동기화 규칙 없음",
|
||||
"lastSynced": "마지막 동기화 ${time}",
|
||||
"manageSyncRule": "동기화 관리",
|
||||
"editEpisodeCount": "에피소드 수"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "셰이더",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Neste ${count} usette",
|
||||
"customAmount": "Egendefinert antall...",
|
||||
"howManyEpisodes": "Hvor mange episoder?",
|
||||
"itemsQueued": "${count} elementer i nedlastingskø"
|
||||
"itemsQueued": "${count} elementer i nedlastingskø",
|
||||
"keepSynced": "Hold synkronisert",
|
||||
"downloadOnce": "Last ned én gang",
|
||||
"keepNUnwatched": "Behold ${count} usette",
|
||||
"editSyncRule": "Rediger synkroniseringsregel",
|
||||
"removeSyncRule": "Fjern synkroniseringsregel",
|
||||
"removeSyncRuleConfirm": "Slutte å synkronisere \"${title}\"? Nedlastede episoder beholdes.",
|
||||
"syncRuleCreated": "Synkroniseringsregel opprettet — beholder ${count} usette episoder",
|
||||
"syncRuleUpdated": "Synkroniseringsregel oppdatert",
|
||||
"syncRuleRemoved": "Synkroniseringsregel fjernet",
|
||||
"syncedNewEpisodes": "Synkroniserte ${count} nye episoder for ${title}",
|
||||
"activeSyncRules": "Synkroniseringsregler",
|
||||
"noSyncRules": "Ingen synkroniseringsregler",
|
||||
"lastSynced": "Sist synkronisert ${time}",
|
||||
"manageSyncRule": "Administrer synkronisering",
|
||||
"editEpisodeCount": "Antall episoder"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shadere",
|
||||
|
||||
+16
-1
@@ -647,7 +647,22 @@
|
||||
"nextNUnwatched": "Volgende ${count} onbekeken",
|
||||
"customAmount": "Aangepast aantal...",
|
||||
"howManyEpisodes": "Hoeveel afleveringen?",
|
||||
"itemsQueued": "${count} items in downloadwachtrij"
|
||||
"itemsQueued": "${count} items in downloadwachtrij",
|
||||
"keepSynced": "Gesynchroniseerd houden",
|
||||
"downloadOnce": "Eenmalig downloaden",
|
||||
"keepNUnwatched": "${count} onbekeken behouden",
|
||||
"editSyncRule": "Synchronisatieregel bewerken",
|
||||
"removeSyncRule": "Synchronisatieregel verwijderen",
|
||||
"removeSyncRuleConfirm": "Synchronisatie van \"${title}\" stoppen? Gedownloade afleveringen worden behouden.",
|
||||
"syncRuleCreated": "Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden",
|
||||
"syncRuleUpdated": "Synchronisatieregel bijgewerkt",
|
||||
"syncRuleRemoved": "Synchronisatieregel verwijderd",
|
||||
"syncedNewEpisodes": "${count} nieuwe afleveringen gesynchroniseerd voor ${title}",
|
||||
"activeSyncRules": "Synchronisatieregels",
|
||||
"noSyncRules": "Geen synchronisatieregels",
|
||||
"lastSynced": "Laatst gesynchroniseerd ${time}",
|
||||
"manageSyncRule": "Synchronisatie beheren",
|
||||
"editEpisodeCount": "Aantal afleveringen"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Afspeellijsten",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Następne ${count} nieobejrzanych",
|
||||
"customAmount": "Własna ilość...",
|
||||
"howManyEpisodes": "Ile odcinków?",
|
||||
"itemsQueued": "${count} elementów dodanych do kolejki pobierania"
|
||||
"itemsQueued": "${count} elementów dodanych do kolejki pobierania",
|
||||
"keepSynced": "Synchronizuj na bieżąco",
|
||||
"downloadOnce": "Pobierz raz",
|
||||
"keepNUnwatched": "Zachowaj ${count} nieobejrzanych",
|
||||
"editSyncRule": "Edytuj regułę synchronizacji",
|
||||
"removeSyncRule": "Usuń regułę synchronizacji",
|
||||
"removeSyncRuleConfirm": "Zatrzymać synchronizację \"${title}\"? Pobrane odcinki zostaną zachowane.",
|
||||
"syncRuleCreated": "Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków",
|
||||
"syncRuleUpdated": "Reguła synchronizacji zaktualizowana",
|
||||
"syncRuleRemoved": "Reguła synchronizacji usunięta",
|
||||
"syncedNewEpisodes": "Zsynchronizowano ${count} nowych odcinków dla ${title}",
|
||||
"activeSyncRules": "Reguły synchronizacji",
|
||||
"noSyncRules": "Brak reguł synchronizacji",
|
||||
"lastSynced": "Ostatnia synchronizacja ${time}",
|
||||
"manageSyncRule": "Zarządzaj synchronizacją",
|
||||
"editEpisodeCount": "Liczba odcinków"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shadery",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Próximos ${count} não assistidos",
|
||||
"customAmount": "Quantidade personalizada...",
|
||||
"howManyEpisodes": "Quantos episódios?",
|
||||
"itemsQueued": "${count} itens na fila de download"
|
||||
"itemsQueued": "${count} itens na fila de download",
|
||||
"keepSynced": "Manter sincronizado",
|
||||
"downloadOnce": "Baixar uma vez",
|
||||
"keepNUnwatched": "Manter ${count} não assistidos",
|
||||
"editSyncRule": "Editar regra de sincronização",
|
||||
"removeSyncRule": "Remover regra de sincronização",
|
||||
"removeSyncRuleConfirm": "Parar de sincronizar \"${title}\"? Os episódios baixados serão mantidos.",
|
||||
"syncRuleCreated": "Regra de sincronização criada — mantendo ${count} episódios não assistidos",
|
||||
"syncRuleUpdated": "Regra de sincronização atualizada",
|
||||
"syncRuleRemoved": "Regra de sincronização removida",
|
||||
"syncedNewEpisodes": "${count} novos episódios sincronizados para ${title}",
|
||||
"activeSyncRules": "Regras de sincronização",
|
||||
"noSyncRules": "Nenhuma regra de sincronização",
|
||||
"lastSynced": "Última sincronização ${time}",
|
||||
"manageSyncRule": "Gerenciar sincronização",
|
||||
"editEpisodeCount": "Número de episódios"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Shaders",
|
||||
|
||||
+16
-1
@@ -758,7 +758,22 @@
|
||||
"nextNUnwatched": "Следующие ${count} непросмотренных",
|
||||
"customAmount": "Указать количество...",
|
||||
"howManyEpisodes": "Сколько эпизодов?",
|
||||
"itemsQueued": "${count} элементов добавлено в очередь загрузки"
|
||||
"itemsQueued": "${count} элементов добавлено в очередь загрузки",
|
||||
"keepSynced": "Синхронизировать",
|
||||
"downloadOnce": "Скачать один раз",
|
||||
"keepNUnwatched": "Хранить ${count} непросмотренных",
|
||||
"editSyncRule": "Редактировать правило синхронизации",
|
||||
"removeSyncRule": "Удалить правило синхронизации",
|
||||
"removeSyncRuleConfirm": "Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.",
|
||||
"syncRuleCreated": "Правило синхронизации создано — хранится ${count} непросмотренных эпизодов",
|
||||
"syncRuleUpdated": "Правило синхронизации обновлено",
|
||||
"syncRuleRemoved": "Правило синхронизации удалено",
|
||||
"syncedNewEpisodes": "Синхронизировано ${count} новых эпизодов для ${title}",
|
||||
"activeSyncRules": "Правила синхронизации",
|
||||
"noSyncRules": "Нет правил синхронизации",
|
||||
"lastSynced": "Последняя синхронизация ${time}",
|
||||
"manageSyncRule": "Управление синхронизацией",
|
||||
"editEpisodeCount": "Количество эпизодов"
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Шейдеры",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 15
|
||||
/// Strings: 13005 (867 per locale)
|
||||
/// Strings: 13230 (882 per locale)
|
||||
///
|
||||
/// Built on 2026-04-14 at 20:28 UTC
|
||||
/// Built on 2026-04-16 at 10:46 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Angiv antal...';
|
||||
@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';
|
||||
@override String get downloadOnce => 'Download én gang';
|
||||
@override String keepNUnwatched({required Object count}) => 'Behold ${count} usete';
|
||||
@override String get editSyncRule => 'Rediger synkroniseringsregel';
|
||||
@override String get removeSyncRule => 'Fjern synkroniseringsregel';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Stop synkronisering af "${title}"? Downloadede episoder beholdes.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Synkroniseringsregel oprettet — beholder ${count} usete episoder';
|
||||
@override String get syncRuleUpdated => 'Synkroniseringsregel opdateret';
|
||||
@override String get syncRuleRemoved => 'Synkroniseringsregel fjernet';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => 'Synkroniserede ${count} nye episoder for ${title}';
|
||||
@override String get activeSyncRules => 'Synkroniseringsregler';
|
||||
@override String get noSyncRules => 'Ingen synkroniseringsregler';
|
||||
@override String lastSynced({required Object time}) => 'Sidst synkroniseret ${time}';
|
||||
@override String get manageSyncRule => 'Administrer synkronisering';
|
||||
@override String get editEpisodeCount => 'Antal episoder';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsDa {
|
||||
'downloads.customAmount' => 'Angiv antal...',
|
||||
'downloads.howManyEpisodes' => 'Hvor mange episoder?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementer sat i kø til download',
|
||||
'downloads.keepSynced' => 'Hold synkroniseret',
|
||||
'downloads.downloadOnce' => 'Download én gang',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Behold ${count} usete',
|
||||
'downloads.editSyncRule' => 'Rediger synkroniseringsregel',
|
||||
'downloads.removeSyncRule' => 'Fjern synkroniseringsregel',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop synkronisering af "${title}"? Downloadede episoder beholdes.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Synkroniseringsregel oprettet — beholder ${count} usete episoder',
|
||||
'downloads.syncRuleUpdated' => 'Synkroniseringsregel opdateret',
|
||||
'downloads.syncRuleRemoved' => 'Synkroniseringsregel fjernet',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserede ${count} nye episoder for ${title}',
|
||||
'downloads.activeSyncRules' => 'Synkroniseringsregler',
|
||||
'downloads.noSyncRules' => 'Ingen synkroniseringsregler',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Sidst synkroniseret ${time}',
|
||||
'downloads.manageSyncRule' => 'Administrer synkronisering',
|
||||
'downloads.editEpisodeCount' => 'Antal episoder',
|
||||
'shaders.title' => 'Shadere',
|
||||
'shaders.noShaderDescription' => 'Ingen videoforbedring',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA-billedskalering for skarpere video',
|
||||
|
||||
@@ -887,6 +887,21 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Eigene Anzahl...';
|
||||
@override String get howManyEpisodes => 'Wie viele Episoden?';
|
||||
@override String itemsQueued({required Object count}) => '${count} Elemente zum Download eingereiht';
|
||||
@override String get keepSynced => 'Synchronisiert halten';
|
||||
@override String get downloadOnce => 'Einmal herunterladen';
|
||||
@override String keepNUnwatched({required Object count}) => '${count} ungesehene behalten';
|
||||
@override String get editSyncRule => 'Sync-Regel bearbeiten';
|
||||
@override String get removeSyncRule => 'Sync-Regel entfernen';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Synchronisierung von "${title}" beenden? Heruntergeladene Episoden werden behalten.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Sync-Regel erstellt — ${count} ungesehene Episoden werden behalten';
|
||||
@override String get syncRuleUpdated => 'Sync-Regel aktualisiert';
|
||||
@override String get syncRuleRemoved => 'Sync-Regel entfernt';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => '${count} neue Episoden für ${title} synchronisiert';
|
||||
@override String get activeSyncRules => 'Sync-Regeln';
|
||||
@override String get noSyncRules => 'Keine Sync-Regeln';
|
||||
@override String lastSynced({required Object time}) => 'Zuletzt synchronisiert ${time}';
|
||||
@override String get manageSyncRule => 'Synchronisierung verwalten';
|
||||
@override String get editEpisodeCount => 'Episodenanzahl';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1950,6 +1965,21 @@ extension on TranslationsDe {
|
||||
'downloads.customAmount' => 'Eigene Anzahl...',
|
||||
'downloads.howManyEpisodes' => 'Wie viele Episoden?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} Elemente zum Download eingereiht',
|
||||
'downloads.keepSynced' => 'Synchronisiert halten',
|
||||
'downloads.downloadOnce' => 'Einmal herunterladen',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => '${count} ungesehene behalten',
|
||||
'downloads.editSyncRule' => 'Sync-Regel bearbeiten',
|
||||
'downloads.removeSyncRule' => 'Sync-Regel entfernen',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Synchronisierung von "${title}" beenden? Heruntergeladene Episoden werden behalten.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Sync-Regel erstellt — ${count} ungesehene Episoden werden behalten',
|
||||
'downloads.syncRuleUpdated' => 'Sync-Regel aktualisiert',
|
||||
'downloads.syncRuleRemoved' => 'Sync-Regel entfernt',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} neue Episoden für ${title} synchronisiert',
|
||||
'downloads.activeSyncRules' => 'Sync-Regeln',
|
||||
'downloads.noSyncRules' => 'Keine Sync-Regeln',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Zuletzt synchronisiert ${time}',
|
||||
'downloads.manageSyncRule' => 'Synchronisierung verwalten',
|
||||
'downloads.editEpisodeCount' => 'Episodenanzahl',
|
||||
'playlists.title' => 'Wiedergabelisten',
|
||||
'playlists.noPlaylists' => 'Keine Wiedergabelisten gefunden',
|
||||
'playlists.create' => 'Wiedergabeliste erstellen',
|
||||
|
||||
@@ -2329,6 +2329,51 @@ class TranslationsDownloadsEn {
|
||||
|
||||
/// en: '${count} items queued for download'
|
||||
String itemsQueued({required Object count}) => '${count} items queued for download';
|
||||
|
||||
/// en: 'Keep synced'
|
||||
String get keepSynced => 'Keep synced';
|
||||
|
||||
/// en: 'Download once'
|
||||
String get downloadOnce => 'Download once';
|
||||
|
||||
/// en: 'Keep ${count} unwatched'
|
||||
String keepNUnwatched({required Object count}) => 'Keep ${count} unwatched';
|
||||
|
||||
/// en: 'Edit sync rule'
|
||||
String get editSyncRule => 'Edit sync rule';
|
||||
|
||||
/// en: 'Remove sync rule'
|
||||
String get removeSyncRule => 'Remove sync rule';
|
||||
|
||||
/// en: 'Stop syncing "${title}"? Downloaded episodes will be kept.'
|
||||
String removeSyncRuleConfirm({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.';
|
||||
|
||||
/// en: 'Sync rule created — keeping ${count} unwatched episodes'
|
||||
String syncRuleCreated({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes';
|
||||
|
||||
/// en: 'Sync rule updated'
|
||||
String get syncRuleUpdated => 'Sync rule updated';
|
||||
|
||||
/// en: 'Sync rule removed'
|
||||
String get syncRuleRemoved => 'Sync rule removed';
|
||||
|
||||
/// en: 'Synced ${count} new episodes for ${title}'
|
||||
String syncedNewEpisodes({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}';
|
||||
|
||||
/// en: 'Sync rules'
|
||||
String get activeSyncRules => 'Sync rules';
|
||||
|
||||
/// en: 'No sync rules'
|
||||
String get noSyncRules => 'No sync rules';
|
||||
|
||||
/// en: 'Last synced ${time}'
|
||||
String lastSynced({required Object time}) => 'Last synced ${time}';
|
||||
|
||||
/// en: 'Manage sync'
|
||||
String get manageSyncRule => 'Manage sync';
|
||||
|
||||
/// en: 'Episode count'
|
||||
String get editEpisodeCount => 'Episode count';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -3796,6 +3841,21 @@ extension on Translations {
|
||||
'downloads.customAmount' => 'Custom amount...',
|
||||
'downloads.howManyEpisodes' => 'How many episodes?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download',
|
||||
'downloads.keepSynced' => 'Keep synced',
|
||||
'downloads.downloadOnce' => 'Download once',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Keep ${count} unwatched',
|
||||
'downloads.editSyncRule' => 'Edit sync rule',
|
||||
'downloads.removeSyncRule' => 'Remove sync rule',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Stop syncing "${title}"? Downloaded episodes will be kept.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Sync rule created — keeping ${count} unwatched episodes',
|
||||
'downloads.syncRuleUpdated' => 'Sync rule updated',
|
||||
'downloads.syncRuleRemoved' => 'Sync rule removed',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synced ${count} new episodes for ${title}',
|
||||
'downloads.activeSyncRules' => 'Sync rules',
|
||||
'downloads.noSyncRules' => 'No sync rules',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Last synced ${time}',
|
||||
'downloads.manageSyncRule' => 'Manage sync',
|
||||
'downloads.editEpisodeCount' => 'Episode count',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'No video enhancement',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Cantidad personalizada...';
|
||||
@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';
|
||||
@override String get downloadOnce => 'Descargar una vez';
|
||||
@override String keepNUnwatched({required Object count}) => 'Mantener ${count} sin ver';
|
||||
@override String get editSyncRule => 'Editar regla de sincronización';
|
||||
@override String get removeSyncRule => 'Eliminar regla de sincronización';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => '¿Dejar de sincronizar "${title}"? Los episodios descargados se conservarán.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Regla de sincronización creada — conservando ${count} episodios sin ver';
|
||||
@override String get syncRuleUpdated => 'Regla de sincronización actualizada';
|
||||
@override String get syncRuleRemoved => 'Regla de sincronización eliminada';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => '${count} nuevos episodios sincronizados para ${title}';
|
||||
@override String get activeSyncRules => 'Reglas de sincronización';
|
||||
@override String get noSyncRules => 'Sin reglas de sincronización';
|
||||
@override String lastSynced({required Object time}) => 'Última sincronización ${time}';
|
||||
@override String get manageSyncRule => 'Gestionar sincronización';
|
||||
@override String get editEpisodeCount => 'Número de episodios';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsEs {
|
||||
'downloads.customAmount' => 'Cantidad personalizada...',
|
||||
'downloads.howManyEpisodes' => '¿Cuántos episodios?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementos en cola de descarga',
|
||||
'downloads.keepSynced' => 'Mantener sincronizado',
|
||||
'downloads.downloadOnce' => 'Descargar una vez',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Mantener ${count} sin ver',
|
||||
'downloads.editSyncRule' => 'Editar regla de sincronización',
|
||||
'downloads.removeSyncRule' => 'Eliminar regla de sincronización',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => '¿Dejar de sincronizar "${title}"? Los episodios descargados se conservarán.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Regla de sincronización creada — conservando ${count} episodios sin ver',
|
||||
'downloads.syncRuleUpdated' => 'Regla de sincronización actualizada',
|
||||
'downloads.syncRuleRemoved' => 'Regla de sincronización eliminada',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nuevos episodios sincronizados para ${title}',
|
||||
'downloads.activeSyncRules' => 'Reglas de sincronización',
|
||||
'downloads.noSyncRules' => 'Sin reglas de sincronización',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Última sincronización ${time}',
|
||||
'downloads.manageSyncRule' => 'Gestionar sincronización',
|
||||
'downloads.editEpisodeCount' => 'Número de episodios',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'Sin mejora de video',
|
||||
'shaders.nvscalerDescription' => 'Escalado de imagen NVIDIA para un video más nítido',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Quantité personnalisée...';
|
||||
@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é';
|
||||
@override String get downloadOnce => 'Télécharger une fois';
|
||||
@override String keepNUnwatched({required Object count}) => 'Garder ${count} non vus';
|
||||
@override String get editSyncRule => 'Modifier la règle de synchronisation';
|
||||
@override String get removeSyncRule => 'Supprimer la règle de synchronisation';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Règle de synchronisation créée — ${count} épisodes non vus conservés';
|
||||
@override String get syncRuleUpdated => 'Règle de synchronisation mise à jour';
|
||||
@override String get syncRuleRemoved => 'Règle de synchronisation supprimée';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => '${count} nouveaux épisodes synchronisés pour ${title}';
|
||||
@override String get activeSyncRules => 'Règles de synchronisation';
|
||||
@override String get noSyncRules => 'Aucune règle de synchronisation';
|
||||
@override String lastSynced({required Object time}) => 'Dernière synchronisation ${time}';
|
||||
@override String get manageSyncRule => 'Gérer la synchronisation';
|
||||
@override String get editEpisodeCount => 'Nombre d’épisodes';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsFr {
|
||||
'downloads.customAmount' => 'Quantité personnalisée...',
|
||||
'downloads.howManyEpisodes' => 'Combien d\'épisodes ?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} éléments mis en file d\'attente',
|
||||
'downloads.keepSynced' => 'Garder synchronisé',
|
||||
'downloads.downloadOnce' => 'Télécharger une fois',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Garder ${count} non vus',
|
||||
'downloads.editSyncRule' => 'Modifier la règle de synchronisation',
|
||||
'downloads.removeSyncRule' => 'Supprimer la règle de synchronisation',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Arrêter la synchronisation de « ${title} » ? Les épisodes téléchargés seront conservés.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Règle de synchronisation créée — ${count} épisodes non vus conservés',
|
||||
'downloads.syncRuleUpdated' => 'Règle de synchronisation mise à jour',
|
||||
'downloads.syncRuleRemoved' => 'Règle de synchronisation supprimée',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nouveaux épisodes synchronisés pour ${title}',
|
||||
'downloads.activeSyncRules' => 'Règles de synchronisation',
|
||||
'downloads.noSyncRules' => 'Aucune règle de synchronisation',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Dernière synchronisation ${time}',
|
||||
'downloads.manageSyncRule' => 'Gérer la synchronisation',
|
||||
'downloads.editEpisodeCount' => 'Nombre d’épisodes',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'Aucune amélioration vidéo',
|
||||
'shaders.nvscalerDescription' => 'Mise à l\'échelle NVIDIA pour une vidéo plus nette',
|
||||
|
||||
@@ -887,6 +887,21 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Quantità personalizzata...';
|
||||
@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';
|
||||
@override String get downloadOnce => 'Scarica una volta';
|
||||
@override String keepNUnwatched({required Object count}) => 'Mantieni ${count} non visti';
|
||||
@override String get editSyncRule => 'Modifica regola di sincronizzazione';
|
||||
@override String get removeSyncRule => 'Rimuovi regola di sincronizzazione';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Interrompere la sincronizzazione di "${title}"? Gli episodi scaricati verranno mantenuti.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Regola di sincronizzazione creata — ${count} episodi non visti mantenuti';
|
||||
@override String get syncRuleUpdated => 'Regola di sincronizzazione aggiornata';
|
||||
@override String get syncRuleRemoved => 'Regola di sincronizzazione rimossa';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => '${count} nuovi episodi sincronizzati per ${title}';
|
||||
@override String get activeSyncRules => 'Regole di sincronizzazione';
|
||||
@override String get noSyncRules => 'Nessuna regola di sincronizzazione';
|
||||
@override String lastSynced({required Object time}) => 'Ultima sincronizzazione ${time}';
|
||||
@override String get manageSyncRule => 'Gestisci sincronizzazione';
|
||||
@override String get editEpisodeCount => 'Numero di episodi';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1950,6 +1965,21 @@ extension on TranslationsIt {
|
||||
'downloads.customAmount' => 'Quantità personalizzata...',
|
||||
'downloads.howManyEpisodes' => 'Quanti episodi?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementi in coda per il download',
|
||||
'downloads.keepSynced' => 'Mantieni sincronizzato',
|
||||
'downloads.downloadOnce' => 'Scarica una volta',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Mantieni ${count} non visti',
|
||||
'downloads.editSyncRule' => 'Modifica regola di sincronizzazione',
|
||||
'downloads.removeSyncRule' => 'Rimuovi regola di sincronizzazione',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Interrompere la sincronizzazione di "${title}"? Gli episodi scaricati verranno mantenuti.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Regola di sincronizzazione creata — ${count} episodi non visti mantenuti',
|
||||
'downloads.syncRuleUpdated' => 'Regola di sincronizzazione aggiornata',
|
||||
'downloads.syncRuleRemoved' => 'Regola di sincronizzazione rimossa',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nuovi episodi sincronizzati per ${title}',
|
||||
'downloads.activeSyncRules' => 'Regole di sincronizzazione',
|
||||
'downloads.noSyncRules' => 'Nessuna regola di sincronizzazione',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Ultima sincronizzazione ${time}',
|
||||
'downloads.manageSyncRule' => 'Gestisci sincronizzazione',
|
||||
'downloads.editEpisodeCount' => 'Numero di episodi',
|
||||
'playlists.title' => 'Playlist',
|
||||
'playlists.noPlaylists' => 'Nessuna playlist trovata',
|
||||
'playlists.create' => 'Crea playlist',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => '数を指定...';
|
||||
@override String get howManyEpisodes => '何エピソード?';
|
||||
@override String itemsQueued({required Object count}) => '${count}件をダウンロードキューに追加';
|
||||
@override String get keepSynced => '同期を維持';
|
||||
@override String get downloadOnce => '一度だけダウンロード';
|
||||
@override String keepNUnwatched({required Object count}) => '未視聴を${count}件保持';
|
||||
@override String get editSyncRule => '同期ルールを編集';
|
||||
@override String get removeSyncRule => '同期ルールを削除';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => '「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。';
|
||||
@override String syncRuleCreated({required Object count}) => '同期ルールを作成しました — 未視聴のエピソードを${count}件保持';
|
||||
@override String get syncRuleUpdated => '同期ルールを更新しました';
|
||||
@override String get syncRuleRemoved => '同期ルールを削除しました';
|
||||
@override String syncedNewEpisodes({required Object title, required Object count}) => '${title}の新しいエピソードを${count}件同期しました';
|
||||
@override String get activeSyncRules => '同期ルール';
|
||||
@override String get noSyncRules => '同期ルールなし';
|
||||
@override String lastSynced({required Object time}) => '最終同期 ${time}';
|
||||
@override String get manageSyncRule => '同期を管理';
|
||||
@override String get editEpisodeCount => 'エピソード数';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsJa {
|
||||
'downloads.customAmount' => '数を指定...',
|
||||
'downloads.howManyEpisodes' => '何エピソード?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count}件をダウンロードキューに追加',
|
||||
'downloads.keepSynced' => '同期を維持',
|
||||
'downloads.downloadOnce' => '一度だけダウンロード',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => '未視聴を${count}件保持',
|
||||
'downloads.editSyncRule' => '同期ルールを編集',
|
||||
'downloads.removeSyncRule' => '同期ルールを削除',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => '「${title}」の同期を停止しますか?ダウンロード済みのエピソードは保持されます。',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => '同期ルールを作成しました — 未視聴のエピソードを${count}件保持',
|
||||
'downloads.syncRuleUpdated' => '同期ルールを更新しました',
|
||||
'downloads.syncRuleRemoved' => '同期ルールを削除しました',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title}の新しいエピソードを${count}件同期しました',
|
||||
'downloads.activeSyncRules' => '同期ルール',
|
||||
'downloads.noSyncRules' => '同期ルールなし',
|
||||
'downloads.lastSynced' => ({required Object time}) => '最終同期 ${time}',
|
||||
'downloads.manageSyncRule' => '同期を管理',
|
||||
'downloads.editEpisodeCount' => 'エピソード数',
|
||||
'shaders.title' => 'シェーダー',
|
||||
'shaders.noShaderDescription' => '映像補正なし',
|
||||
'shaders.nvscalerDescription' => 'よりシャープな映像のためのNVIDIA画像スケーリング',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => '직접 입력...';
|
||||
@override String get howManyEpisodes => '몇 개의 에피소드?';
|
||||
@override String itemsQueued({required Object count}) => '${count}개 항목이 다운로드 대기열에 추가됨';
|
||||
@override String get keepSynced => '동기화 유지';
|
||||
@override String get downloadOnce => '한 번만 다운로드';
|
||||
@override String keepNUnwatched({required Object count}) => '미시청 ${count}개 유지';
|
||||
@override String get editSyncRule => '동기화 규칙 편집';
|
||||
@override String get removeSyncRule => '동기화 규칙 제거';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => '"${title}" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.';
|
||||
@override String syncRuleCreated({required Object count}) => '동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지';
|
||||
@override String get syncRuleUpdated => '동기화 규칙 업데이트됨';
|
||||
@override String get syncRuleRemoved => '동기화 규칙 제거됨';
|
||||
@override String syncedNewEpisodes({required Object title, required Object count}) => '${title}의 새 에피소드 ${count}개 동기화됨';
|
||||
@override String get activeSyncRules => '동기화 규칙';
|
||||
@override String get noSyncRules => '동기화 규칙 없음';
|
||||
@override String lastSynced({required Object time}) => '마지막 동기화 ${time}';
|
||||
@override String get manageSyncRule => '동기화 관리';
|
||||
@override String get editEpisodeCount => '에피소드 수';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsKo {
|
||||
'downloads.customAmount' => '직접 입력...',
|
||||
'downloads.howManyEpisodes' => '몇 개의 에피소드?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count}개 항목이 다운로드 대기열에 추가됨',
|
||||
'downloads.keepSynced' => '동기화 유지',
|
||||
'downloads.downloadOnce' => '한 번만 다운로드',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => '미시청 ${count}개 유지',
|
||||
'downloads.editSyncRule' => '동기화 규칙 편집',
|
||||
'downloads.removeSyncRule' => '동기화 규칙 제거',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => '"${title}" 동기화를 중단하시겠습니까? 다운로드된 에피소드는 유지됩니다.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => '동기화 규칙 생성됨 — 미시청 에피소드 ${count}개 유지',
|
||||
'downloads.syncRuleUpdated' => '동기화 규칙 업데이트됨',
|
||||
'downloads.syncRuleRemoved' => '동기화 규칙 제거됨',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '${title}의 새 에피소드 ${count}개 동기화됨',
|
||||
'downloads.activeSyncRules' => '동기화 규칙',
|
||||
'downloads.noSyncRules' => '동기화 규칙 없음',
|
||||
'downloads.lastSynced' => ({required Object time}) => '마지막 동기화 ${time}',
|
||||
'downloads.manageSyncRule' => '동기화 관리',
|
||||
'downloads.editEpisodeCount' => '에피소드 수',
|
||||
'shaders.title' => '셰이더',
|
||||
'shaders.noShaderDescription' => '비디오 향상 없음',
|
||||
'shaders.nvscalerDescription' => '더 선명한 비디오를 위한 NVIDIA 이미지 스케일링',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Egendefinert antall...';
|
||||
@override String get howManyEpisodes => 'Hvor mange episoder?';
|
||||
@override String itemsQueued({required Object count}) => '${count} elementer i nedlastingskø';
|
||||
@override String get keepSynced => 'Hold synkronisert';
|
||||
@override String get downloadOnce => 'Last ned én gang';
|
||||
@override String keepNUnwatched({required Object count}) => 'Behold ${count} usette';
|
||||
@override String get editSyncRule => 'Rediger synkroniseringsregel';
|
||||
@override String get removeSyncRule => 'Fjern synkroniseringsregel';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Slutte å synkronisere "${title}"? Nedlastede episoder beholdes.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Synkroniseringsregel opprettet — beholder ${count} usette episoder';
|
||||
@override String get syncRuleUpdated => 'Synkroniseringsregel oppdatert';
|
||||
@override String get syncRuleRemoved => 'Synkroniseringsregel fjernet';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => 'Synkroniserte ${count} nye episoder for ${title}';
|
||||
@override String get activeSyncRules => 'Synkroniseringsregler';
|
||||
@override String get noSyncRules => 'Ingen synkroniseringsregler';
|
||||
@override String lastSynced({required Object time}) => 'Sist synkronisert ${time}';
|
||||
@override String get manageSyncRule => 'Administrer synkronisering';
|
||||
@override String get editEpisodeCount => 'Antall episoder';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsNb {
|
||||
'downloads.customAmount' => 'Egendefinert antall...',
|
||||
'downloads.howManyEpisodes' => 'Hvor mange episoder?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementer i nedlastingskø',
|
||||
'downloads.keepSynced' => 'Hold synkronisert',
|
||||
'downloads.downloadOnce' => 'Last ned én gang',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Behold ${count} usette',
|
||||
'downloads.editSyncRule' => 'Rediger synkroniseringsregel',
|
||||
'downloads.removeSyncRule' => 'Fjern synkroniseringsregel',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Slutte å synkronisere "${title}"? Nedlastede episoder beholdes.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Synkroniseringsregel opprettet — beholder ${count} usette episoder',
|
||||
'downloads.syncRuleUpdated' => 'Synkroniseringsregel oppdatert',
|
||||
'downloads.syncRuleRemoved' => 'Synkroniseringsregel fjernet',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserte ${count} nye episoder for ${title}',
|
||||
'downloads.activeSyncRules' => 'Synkroniseringsregler',
|
||||
'downloads.noSyncRules' => 'Ingen synkroniseringsregler',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Sist synkronisert ${time}',
|
||||
'downloads.manageSyncRule' => 'Administrer synkronisering',
|
||||
'downloads.editEpisodeCount' => 'Antall episoder',
|
||||
'shaders.title' => 'Shadere',
|
||||
'shaders.noShaderDescription' => 'Ingen videoforbedring',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA bildeskalering for skarpere video',
|
||||
|
||||
@@ -887,6 +887,21 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Aangepast aantal...';
|
||||
@override String get howManyEpisodes => 'Hoeveel afleveringen?';
|
||||
@override String itemsQueued({required Object count}) => '${count} items in downloadwachtrij';
|
||||
@override String get keepSynced => 'Gesynchroniseerd houden';
|
||||
@override String get downloadOnce => 'Eenmalig downloaden';
|
||||
@override String keepNUnwatched({required Object count}) => '${count} onbekeken behouden';
|
||||
@override String get editSyncRule => 'Synchronisatieregel bewerken';
|
||||
@override String get removeSyncRule => 'Synchronisatieregel verwijderen';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Synchronisatie van "${title}" stoppen? Gedownloade afleveringen worden behouden.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden';
|
||||
@override String get syncRuleUpdated => 'Synchronisatieregel bijgewerkt';
|
||||
@override String get syncRuleRemoved => 'Synchronisatieregel verwijderd';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => '${count} nieuwe afleveringen gesynchroniseerd voor ${title}';
|
||||
@override String get activeSyncRules => 'Synchronisatieregels';
|
||||
@override String get noSyncRules => 'Geen synchronisatieregels';
|
||||
@override String lastSynced({required Object time}) => 'Laatst gesynchroniseerd ${time}';
|
||||
@override String get manageSyncRule => 'Synchronisatie beheren';
|
||||
@override String get editEpisodeCount => 'Aantal afleveringen';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1950,6 +1965,21 @@ extension on TranslationsNl {
|
||||
'downloads.customAmount' => 'Aangepast aantal...',
|
||||
'downloads.howManyEpisodes' => 'Hoeveel afleveringen?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} items in downloadwachtrij',
|
||||
'downloads.keepSynced' => 'Gesynchroniseerd houden',
|
||||
'downloads.downloadOnce' => 'Eenmalig downloaden',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => '${count} onbekeken behouden',
|
||||
'downloads.editSyncRule' => 'Synchronisatieregel bewerken',
|
||||
'downloads.removeSyncRule' => 'Synchronisatieregel verwijderen',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Synchronisatie van "${title}" stoppen? Gedownloade afleveringen worden behouden.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Synchronisatieregel aangemaakt — ${count} onbekeken afleveringen behouden',
|
||||
'downloads.syncRuleUpdated' => 'Synchronisatieregel bijgewerkt',
|
||||
'downloads.syncRuleRemoved' => 'Synchronisatieregel verwijderd',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} nieuwe afleveringen gesynchroniseerd voor ${title}',
|
||||
'downloads.activeSyncRules' => 'Synchronisatieregels',
|
||||
'downloads.noSyncRules' => 'Geen synchronisatieregels',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Laatst gesynchroniseerd ${time}',
|
||||
'downloads.manageSyncRule' => 'Synchronisatie beheren',
|
||||
'downloads.editEpisodeCount' => 'Aantal afleveringen',
|
||||
'playlists.title' => 'Afspeellijsten',
|
||||
'playlists.noPlaylists' => 'Geen afspeellijsten gevonden',
|
||||
'playlists.create' => 'Afspeellijst maken',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Własna ilość...';
|
||||
@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';
|
||||
@override String get downloadOnce => 'Pobierz raz';
|
||||
@override String keepNUnwatched({required Object count}) => 'Zachowaj ${count} nieobejrzanych';
|
||||
@override String get editSyncRule => 'Edytuj regułę synchronizacji';
|
||||
@override String get removeSyncRule => 'Usuń regułę synchronizacji';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Zatrzymać synchronizację "${title}"? Pobrane odcinki zostaną zachowane.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków';
|
||||
@override String get syncRuleUpdated => 'Reguła synchronizacji zaktualizowana';
|
||||
@override String get syncRuleRemoved => 'Reguła synchronizacji usunięta';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => 'Zsynchronizowano ${count} nowych odcinków dla ${title}';
|
||||
@override String get activeSyncRules => 'Reguły synchronizacji';
|
||||
@override String get noSyncRules => 'Brak reguł synchronizacji';
|
||||
@override String lastSynced({required Object time}) => 'Ostatnia synchronizacja ${time}';
|
||||
@override String get manageSyncRule => 'Zarządzaj synchronizacją';
|
||||
@override String get editEpisodeCount => 'Liczba odcinków';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsPl {
|
||||
'downloads.customAmount' => 'Własna ilość...',
|
||||
'downloads.howManyEpisodes' => 'Ile odcinków?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} elementów dodanych do kolejki pobierania',
|
||||
'downloads.keepSynced' => 'Synchronizuj na bieżąco',
|
||||
'downloads.downloadOnce' => 'Pobierz raz',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Zachowaj ${count} nieobejrzanych',
|
||||
'downloads.editSyncRule' => 'Edytuj regułę synchronizacji',
|
||||
'downloads.removeSyncRule' => 'Usuń regułę synchronizacji',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Zatrzymać synchronizację "${title}"? Pobrane odcinki zostaną zachowane.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Reguła synchronizacji utworzona — zachowywanie ${count} nieobejrzanych odcinków',
|
||||
'downloads.syncRuleUpdated' => 'Reguła synchronizacji zaktualizowana',
|
||||
'downloads.syncRuleRemoved' => 'Reguła synchronizacji usunięta',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Zsynchronizowano ${count} nowych odcinków dla ${title}',
|
||||
'downloads.activeSyncRules' => 'Reguły synchronizacji',
|
||||
'downloads.noSyncRules' => 'Brak reguł synchronizacji',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Ostatnia synchronizacja ${time}',
|
||||
'downloads.manageSyncRule' => 'Zarządzaj synchronizacją',
|
||||
'downloads.editEpisodeCount' => 'Liczba odcinków',
|
||||
'shaders.title' => 'Shadery',
|
||||
'shaders.noShaderDescription' => 'Bez ulepszenia wideo',
|
||||
'shaders.nvscalerDescription' => 'Skalowanie obrazu NVIDIA dla ostrzejszego wideo',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Quantidade personalizada...';
|
||||
@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';
|
||||
@override String get downloadOnce => 'Baixar uma vez';
|
||||
@override String keepNUnwatched({required Object count}) => 'Manter ${count} não assistidos';
|
||||
@override String get editSyncRule => 'Editar regra de sincronização';
|
||||
@override String get removeSyncRule => 'Remover regra de sincronização';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Parar de sincronizar "${title}"? Os episódios baixados serão mantidos.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Regra de sincronização criada — mantendo ${count} episódios não assistidos';
|
||||
@override String get syncRuleUpdated => 'Regra de sincronização atualizada';
|
||||
@override String get syncRuleRemoved => 'Regra de sincronização removida';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => '${count} novos episódios sincronizados para ${title}';
|
||||
@override String get activeSyncRules => 'Regras de sincronização';
|
||||
@override String get noSyncRules => 'Nenhuma regra de sincronização';
|
||||
@override String lastSynced({required Object time}) => 'Última sincronização ${time}';
|
||||
@override String get manageSyncRule => 'Gerenciar sincronização';
|
||||
@override String get editEpisodeCount => 'Número de episódios';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsPt {
|
||||
'downloads.customAmount' => 'Quantidade personalizada...',
|
||||
'downloads.howManyEpisodes' => 'Quantos episódios?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} itens na fila de download',
|
||||
'downloads.keepSynced' => 'Manter sincronizado',
|
||||
'downloads.downloadOnce' => 'Baixar uma vez',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Manter ${count} não assistidos',
|
||||
'downloads.editSyncRule' => 'Editar regra de sincronização',
|
||||
'downloads.removeSyncRule' => 'Remover regra de sincronização',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Parar de sincronizar "${title}"? Os episódios baixados serão mantidos.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Regra de sincronização criada — mantendo ${count} episódios não assistidos',
|
||||
'downloads.syncRuleUpdated' => 'Regra de sincronização atualizada',
|
||||
'downloads.syncRuleRemoved' => 'Regra de sincronização removida',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => '${count} novos episódios sincronizados para ${title}',
|
||||
'downloads.activeSyncRules' => 'Regras de sincronização',
|
||||
'downloads.noSyncRules' => 'Nenhuma regra de sincronização',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Última sincronização ${time}',
|
||||
'downloads.manageSyncRule' => 'Gerenciar sincronização',
|
||||
'downloads.editEpisodeCount' => 'Número de episódios',
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo',
|
||||
'shaders.nvscalerDescription' => 'Escalonamento de imagem NVIDIA para vídeo mais nítido',
|
||||
|
||||
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Указать количество...';
|
||||
@override String get howManyEpisodes => 'Сколько эпизодов?';
|
||||
@override String itemsQueued({required Object count}) => '${count} элементов добавлено в очередь загрузки';
|
||||
@override String get keepSynced => 'Синхронизировать';
|
||||
@override String get downloadOnce => 'Скачать один раз';
|
||||
@override String keepNUnwatched({required Object count}) => 'Хранить ${count} непросмотренных';
|
||||
@override String get editSyncRule => 'Редактировать правило синхронизации';
|
||||
@override String get removeSyncRule => 'Удалить правило синхронизации';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Правило синхронизации создано — хранится ${count} непросмотренных эпизодов';
|
||||
@override String get syncRuleUpdated => 'Правило синхронизации обновлено';
|
||||
@override String get syncRuleRemoved => 'Правило синхронизации удалено';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => 'Синхронизировано ${count} новых эпизодов для ${title}';
|
||||
@override String get activeSyncRules => 'Правила синхронизации';
|
||||
@override String get noSyncRules => 'Нет правил синхронизации';
|
||||
@override String lastSynced({required Object time}) => 'Последняя синхронизация ${time}';
|
||||
@override String get manageSyncRule => 'Управление синхронизацией';
|
||||
@override String get editEpisodeCount => 'Количество эпизодов';
|
||||
}
|
||||
|
||||
// Path: shaders
|
||||
@@ -2055,6 +2070,21 @@ extension on TranslationsRu {
|
||||
'downloads.customAmount' => 'Указать количество...',
|
||||
'downloads.howManyEpisodes' => 'Сколько эпизодов?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} элементов добавлено в очередь загрузки',
|
||||
'downloads.keepSynced' => 'Синхронизировать',
|
||||
'downloads.downloadOnce' => 'Скачать один раз',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Хранить ${count} непросмотренных',
|
||||
'downloads.editSyncRule' => 'Редактировать правило синхронизации',
|
||||
'downloads.removeSyncRule' => 'Удалить правило синхронизации',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Прекратить синхронизацию «${title}»? Скачанные эпизоды будут сохранены.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Правило синхронизации создано — хранится ${count} непросмотренных эпизодов',
|
||||
'downloads.syncRuleUpdated' => 'Правило синхронизации обновлено',
|
||||
'downloads.syncRuleRemoved' => 'Правило синхронизации удалено',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Синхронизировано ${count} новых эпизодов для ${title}',
|
||||
'downloads.activeSyncRules' => 'Правила синхронизации',
|
||||
'downloads.noSyncRules' => 'Нет правил синхронизации',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Последняя синхронизация ${time}',
|
||||
'downloads.manageSyncRule' => 'Управление синхронизацией',
|
||||
'downloads.editEpisodeCount' => 'Количество эпизодов',
|
||||
'shaders.title' => 'Шейдеры',
|
||||
'shaders.noShaderDescription' => 'Без улучшения видео',
|
||||
'shaders.nvscalerDescription' => 'Масштабирование NVIDIA для более чёткого видео',
|
||||
|
||||
@@ -887,6 +887,21 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => 'Ange antal...';
|
||||
@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';
|
||||
@override String get downloadOnce => 'Ladda ner en gång';
|
||||
@override String keepNUnwatched({required Object count}) => 'Behåll ${count} osedda';
|
||||
@override String get editSyncRule => 'Redigera synkregel';
|
||||
@override String get removeSyncRule => 'Ta bort synkregel';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => 'Sluta synkronisera "${title}"? Nedladdade avsnitt behålls.';
|
||||
@override String syncRuleCreated({required Object count}) => 'Synkregel skapad — behåller ${count} osedda avsnitt';
|
||||
@override String get syncRuleUpdated => 'Synkregel uppdaterad';
|
||||
@override String get syncRuleRemoved => 'Synkregel borttagen';
|
||||
@override String syncedNewEpisodes({required Object count, required Object title}) => 'Synkroniserade ${count} nya avsnitt för ${title}';
|
||||
@override String get activeSyncRules => 'Synkregler';
|
||||
@override String get noSyncRules => 'Inga synkregler';
|
||||
@override String lastSynced({required Object time}) => 'Senast synkroniserad ${time}';
|
||||
@override String get manageSyncRule => 'Hantera synkronisering';
|
||||
@override String get editEpisodeCount => 'Antal avsnitt';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1950,6 +1965,21 @@ extension on TranslationsSv {
|
||||
'downloads.customAmount' => 'Ange antal...',
|
||||
'downloads.howManyEpisodes' => 'Hur många avsnitt?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} objekt köade för nedladdning',
|
||||
'downloads.keepSynced' => 'Håll synkroniserad',
|
||||
'downloads.downloadOnce' => 'Ladda ner en gång',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => 'Behåll ${count} osedda',
|
||||
'downloads.editSyncRule' => 'Redigera synkregel',
|
||||
'downloads.removeSyncRule' => 'Ta bort synkregel',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => 'Sluta synkronisera "${title}"? Nedladdade avsnitt behålls.',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => 'Synkregel skapad — behåller ${count} osedda avsnitt',
|
||||
'downloads.syncRuleUpdated' => 'Synkregel uppdaterad',
|
||||
'downloads.syncRuleRemoved' => 'Synkregel borttagen',
|
||||
'downloads.syncedNewEpisodes' => ({required Object count, required Object title}) => 'Synkroniserade ${count} nya avsnitt för ${title}',
|
||||
'downloads.activeSyncRules' => 'Synkregler',
|
||||
'downloads.noSyncRules' => 'Inga synkregler',
|
||||
'downloads.lastSynced' => ({required Object time}) => 'Senast synkroniserad ${time}',
|
||||
'downloads.manageSyncRule' => 'Hantera synkronisering',
|
||||
'downloads.editEpisodeCount' => 'Antal avsnitt',
|
||||
'playlists.title' => 'Spellistor',
|
||||
'playlists.noPlaylists' => 'Inga spellistor hittades',
|
||||
'playlists.create' => 'Skapa spellista',
|
||||
|
||||
@@ -887,6 +887,21 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn {
|
||||
@override String get customAmount => '自定义数量...';
|
||||
@override String get howManyEpisodes => '下载几集?';
|
||||
@override String itemsQueued({required Object count}) => '${count} 个项目已加入下载队列';
|
||||
@override String get keepSynced => '保持同步';
|
||||
@override String get downloadOnce => '下载一次';
|
||||
@override String keepNUnwatched({required Object count}) => '保留${count}个未观看';
|
||||
@override String get editSyncRule => '编辑同步规则';
|
||||
@override String get removeSyncRule => '删除同步规则';
|
||||
@override String removeSyncRuleConfirm({required Object title}) => '停止同步“${title}”?已下载的剧集将被保留。';
|
||||
@override String syncRuleCreated({required Object count}) => '同步规则已创建 — 保留${count}个未观看的剧集';
|
||||
@override String get syncRuleUpdated => '同步规则已更新';
|
||||
@override String get syncRuleRemoved => '同步规则已删除';
|
||||
@override String syncedNewEpisodes({required Object title, required Object count}) => '已为${title}同步${count}个新剧集';
|
||||
@override String get activeSyncRules => '同步规则';
|
||||
@override String get noSyncRules => '没有同步规则';
|
||||
@override String lastSynced({required Object time}) => '上次同步 ${time}';
|
||||
@override String get manageSyncRule => '管理同步';
|
||||
@override String get editEpisodeCount => '剧集数量';
|
||||
}
|
||||
|
||||
// Path: playlists
|
||||
@@ -1950,6 +1965,21 @@ extension on TranslationsZh {
|
||||
'downloads.customAmount' => '自定义数量...',
|
||||
'downloads.howManyEpisodes' => '下载几集?',
|
||||
'downloads.itemsQueued' => ({required Object count}) => '${count} 个项目已加入下载队列',
|
||||
'downloads.keepSynced' => '保持同步',
|
||||
'downloads.downloadOnce' => '下载一次',
|
||||
'downloads.keepNUnwatched' => ({required Object count}) => '保留${count}个未观看',
|
||||
'downloads.editSyncRule' => '编辑同步规则',
|
||||
'downloads.removeSyncRule' => '删除同步规则',
|
||||
'downloads.removeSyncRuleConfirm' => ({required Object title}) => '停止同步“${title}”?已下载的剧集将被保留。',
|
||||
'downloads.syncRuleCreated' => ({required Object count}) => '同步规则已创建 — 保留${count}个未观看的剧集',
|
||||
'downloads.syncRuleUpdated' => '同步规则已更新',
|
||||
'downloads.syncRuleRemoved' => '同步规则已删除',
|
||||
'downloads.syncedNewEpisodes' => ({required Object title, required Object count}) => '已为${title}同步${count}个新剧集',
|
||||
'downloads.activeSyncRules' => '同步规则',
|
||||
'downloads.noSyncRules' => '没有同步规则',
|
||||
'downloads.lastSynced' => ({required Object time}) => '上次同步 ${time}',
|
||||
'downloads.manageSyncRule' => '管理同步',
|
||||
'downloads.editEpisodeCount' => '剧集数量',
|
||||
'playlists.title' => '播放列表',
|
||||
'playlists.noPlaylists' => '未找到播放列表',
|
||||
'playlists.create' => '创建播放列表',
|
||||
|
||||
+16
-1
@@ -647,7 +647,22 @@
|
||||
"nextNUnwatched": "Nästa ${count} osedda",
|
||||
"customAmount": "Ange antal...",
|
||||
"howManyEpisodes": "Hur många avsnitt?",
|
||||
"itemsQueued": "${count} objekt köade för nedladdning"
|
||||
"itemsQueued": "${count} objekt köade för nedladdning",
|
||||
"keepSynced": "Håll synkroniserad",
|
||||
"downloadOnce": "Ladda ner en gång",
|
||||
"keepNUnwatched": "Behåll ${count} osedda",
|
||||
"editSyncRule": "Redigera synkregel",
|
||||
"removeSyncRule": "Ta bort synkregel",
|
||||
"removeSyncRuleConfirm": "Sluta synkronisera \"${title}\"? Nedladdade avsnitt behålls.",
|
||||
"syncRuleCreated": "Synkregel skapad — behåller ${count} osedda avsnitt",
|
||||
"syncRuleUpdated": "Synkregel uppdaterad",
|
||||
"syncRuleRemoved": "Synkregel borttagen",
|
||||
"syncedNewEpisodes": "Synkroniserade ${count} nya avsnitt för ${title}",
|
||||
"activeSyncRules": "Synkregler",
|
||||
"noSyncRules": "Inga synkregler",
|
||||
"lastSynced": "Senast synkroniserad ${time}",
|
||||
"manageSyncRule": "Hantera synkronisering",
|
||||
"editEpisodeCount": "Antal avsnitt"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "Spellistor",
|
||||
|
||||
+16
-1
@@ -647,7 +647,22 @@
|
||||
"nextNUnwatched": "接下来 ${count} 集未观看",
|
||||
"customAmount": "自定义数量...",
|
||||
"howManyEpisodes": "下载几集?",
|
||||
"itemsQueued": "${count} 个项目已加入下载队列"
|
||||
"itemsQueued": "${count} 个项目已加入下载队列",
|
||||
"keepSynced": "保持同步",
|
||||
"downloadOnce": "下载一次",
|
||||
"keepNUnwatched": "保留${count}个未观看",
|
||||
"editSyncRule": "编辑同步规则",
|
||||
"removeSyncRule": "删除同步规则",
|
||||
"removeSyncRuleConfirm": "停止同步“${title}”?已下载的剧集将被保留。",
|
||||
"syncRuleCreated": "同步规则已创建 — 保留${count}个未观看的剧集",
|
||||
"syncRuleUpdated": "同步规则已更新",
|
||||
"syncRuleRemoved": "同步规则已删除",
|
||||
"syncedNewEpisodes": "已为${title}同步${count}个新剧集",
|
||||
"activeSyncRules": "同步规则",
|
||||
"noSyncRules": "没有同步规则",
|
||||
"lastSynced": "上次同步 ${time}",
|
||||
"manageSyncRule": "管理同步",
|
||||
"editEpisodeCount": "剧集数量"
|
||||
},
|
||||
"playlists": {
|
||||
"title": "播放列表",
|
||||
|
||||
+50
-13
@@ -44,8 +44,10 @@ import 'services/download_storage_service.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'services/plex_api_cache.dart';
|
||||
import 'database/app_database.dart';
|
||||
import 'screens/video_player_screen.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
import 'utils/orientation_helper.dart';
|
||||
import 'utils/watch_state_notifier.dart';
|
||||
import 'i18n/strings.g.dart';
|
||||
import 'focus/input_mode_tracker.dart';
|
||||
import 'focus/key_event_utils.dart';
|
||||
@@ -362,6 +364,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
late final DownloadManagerService _downloadManager;
|
||||
late final OfflineWatchSyncService _offlineWatchSyncService;
|
||||
late final AppLifecycleListener _appLifecycleListener;
|
||||
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
|
||||
Timer? _syncDebounce;
|
||||
bool _isAutoDeleteRunning = false;
|
||||
|
||||
/// Last time server health probes ran from a resume event (cooldown for desktop)
|
||||
DateTime _lastResumeProbe = DateTime(0);
|
||||
@@ -411,6 +416,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_syncDebounce?.cancel();
|
||||
_watchStateSubscription?.cancel();
|
||||
_memoryCheckTimer?.cancel();
|
||||
_appLifecycleListener.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -429,6 +436,34 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
}
|
||||
|
||||
/// Auto-delete watched downloads and execute sync rules.
|
||||
/// Shared by onWatchStatesRefreshed and the WatchStateNotifier listener.
|
||||
Future<void> _autoDeleteAndSync(DownloadProvider downloadProvider) async {
|
||||
if (_isAutoDeleteRunning) return;
|
||||
_isAutoDeleteRunning = true;
|
||||
try {
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
final activeKey = VideoPlayerScreenState.activeRatingKey;
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && settings.getAutoRemoveWatchedDownloads()) {
|
||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey);
|
||||
if (deleted.isNotEmpty) {
|
||||
final msg = deleted.length == 1
|
||||
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
||||
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
||||
showGlobalSnackBar(msg);
|
||||
}
|
||||
}
|
||||
|
||||
final synced = await downloadProvider.executeSyncRules(_serverManager);
|
||||
if (synced.isNotEmpty) {
|
||||
showGlobalSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
|
||||
}
|
||||
} finally {
|
||||
_isAutoDeleteRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
@@ -486,28 +521,30 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
// Download provider
|
||||
ChangeNotifierProvider(create: (context) => DownloadProvider(downloadManager: _downloadManager)),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase)),
|
||||
// Offline watch sync service
|
||||
ChangeNotifierProvider<OfflineWatchSyncService>(
|
||||
create: (context) {
|
||||
final offlineModeProvider = context.read<OfflineModeProvider>();
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
|
||||
// Wire up callback to refresh download provider after watch state sync
|
||||
_offlineWatchSyncService.onWatchStatesRefreshed = () async {
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && settings.getAutoRemoveWatchedDownloads()) {
|
||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads();
|
||||
if (deleted.isNotEmpty) {
|
||||
final msg = deleted.length == 1
|
||||
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
||||
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
||||
showGlobalSnackBar(msg);
|
||||
}
|
||||
}
|
||||
await _autoDeleteAndSync(downloadProvider);
|
||||
};
|
||||
|
||||
// Also trigger sync rules when watch state changes during a session.
|
||||
// Debounced to batch rapid changes (binge watching, bulk mark-watched).
|
||||
_watchStateSubscription = WatchStateNotifier().stream.listen((event) {
|
||||
if (event.changeType != WatchStateChangeType.watched) return;
|
||||
if (VideoPlayerScreenState.activeRatingKey == event.ratingKey) return;
|
||||
|
||||
_syncDebounce?.cancel();
|
||||
_syncDebounce = Timer(const Duration(seconds: 5), () {
|
||||
_autoDeleteAndSync(downloadProvider);
|
||||
});
|
||||
});
|
||||
|
||||
_offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
|
||||
return _offlineWatchSyncService;
|
||||
},
|
||||
|
||||
@@ -152,6 +152,15 @@ class PlexMetadata with MultiServerFields {
|
||||
/// Global unique identifier across all servers (serverId:ratingKey)
|
||||
String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey;
|
||||
|
||||
/// Parent rating keys for hierarchical invalidation.
|
||||
/// For an episode: [seasonRatingKey, showRatingKey]
|
||||
/// For a season: [showRatingKey]
|
||||
/// For a movie: []
|
||||
List<String> get parentChain => [
|
||||
?parentRatingKey,
|
||||
?grandparentRatingKey,
|
||||
];
|
||||
|
||||
/// Whether this item represents a library section (shared whole-library, not a media item).
|
||||
/// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`.
|
||||
bool get isLibrarySection => key != null && key!.startsWith('/library/sections/');
|
||||
@@ -520,6 +529,10 @@ class PlexMetadata with MultiServerFields {
|
||||
return viewOffset! > 0 && viewOffset! < duration!;
|
||||
}
|
||||
|
||||
/// Returns true if this container (show/season) has some but not all episodes watched
|
||||
bool get isPartiallyWatched =>
|
||||
viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!;
|
||||
|
||||
// Helper to determine if content is watched
|
||||
bool get isWatched {
|
||||
// For series/seasons, check if all episodes are watched
|
||||
|
||||
@@ -6,11 +6,14 @@ import '../models/download_models.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/download_version_utils.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../services/download_manager_service.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/plex_api_cache.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../services/sync_rule_executor.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
|
||||
@@ -36,6 +39,8 @@ class DownloadedArtwork {
|
||||
/// Provider for managing download state and operations.
|
||||
class DownloadProvider extends ChangeNotifier {
|
||||
final DownloadManagerService _downloadManager;
|
||||
final AppDatabase _database;
|
||||
final SyncRuleExecutor _syncRuleExecutor;
|
||||
StreamSubscription<DownloadProgress>? _progressSubscription;
|
||||
StreamSubscription<DeletionProgress>? _deletionProgressSubscription;
|
||||
late final Future<void> _initFuture;
|
||||
@@ -59,7 +64,13 @@ class DownloadProvider extends ChangeNotifier {
|
||||
// Key: globalKey (serverId:ratingKey), Value: total episode count
|
||||
final Map<String, int> _totalEpisodeCounts = {};
|
||||
|
||||
DownloadProvider({required DownloadManagerService downloadManager}) : _downloadManager = downloadManager {
|
||||
// Persistent sync rules: globalKey -> SyncRuleItem
|
||||
final Map<String, SyncRuleItem> _syncRules = {};
|
||||
|
||||
DownloadProvider({required DownloadManagerService downloadManager, required AppDatabase database})
|
||||
: _downloadManager = downloadManager,
|
||||
_database = database,
|
||||
_syncRuleExecutor = SyncRuleExecutor(database: database) {
|
||||
// Listen to progress updates from the download manager
|
||||
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
||||
|
||||
@@ -127,9 +138,12 @@ class DownloadProvider extends ChangeNotifier {
|
||||
// Load total episode counts from StorageService
|
||||
await _loadTotalEpisodeCounts();
|
||||
|
||||
// Load sync rules from database
|
||||
await _loadSyncRules();
|
||||
|
||||
appLogger.i(
|
||||
'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, '
|
||||
'and ${_totalEpisodeCounts.length} episode counts',
|
||||
'${_totalEpisodeCounts.length} episode counts, and ${_syncRules.length} sync rules',
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -988,7 +1002,8 @@ class DownloadProvider extends ChangeNotifier {
|
||||
/// Auto-delete downloaded episodes/movies that are now marked as watched.
|
||||
///
|
||||
/// Only deletes individual episodes and movies, never show/season containers.
|
||||
Future<List<String>> autoDeleteWatchedDownloads() async {
|
||||
/// [activeRatingKey] is excluded from deletion to protect the currently playing item.
|
||||
Future<List<String>> autoDeleteWatchedDownloads({String? activeRatingKey}) async {
|
||||
final deletedTitles = <String>[];
|
||||
|
||||
final completedKeys = _downloads.entries
|
||||
@@ -1002,6 +1017,9 @@ class DownloadProvider extends ChangeNotifier {
|
||||
if (!meta.isEpisode && !meta.isMovie) continue;
|
||||
if (!meta.isWatched) continue;
|
||||
|
||||
// Don't delete the episode that's currently playing
|
||||
if (activeRatingKey != null && meta.ratingKey == activeRatingKey) continue;
|
||||
|
||||
try {
|
||||
appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)');
|
||||
await deleteDownload(globalKey);
|
||||
@@ -1013,6 +1031,108 @@ class DownloadProvider extends ChangeNotifier {
|
||||
|
||||
return deletedTitles;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sync Rules
|
||||
// ============================================================
|
||||
|
||||
/// All sync rules (globalKey -> SyncRuleItem)
|
||||
Map<String, SyncRuleItem> get syncRules => Map.unmodifiable(_syncRules);
|
||||
|
||||
/// Check if a sync rule exists for the given item
|
||||
bool hasSyncRule(String globalKey) => _syncRules.containsKey(globalKey);
|
||||
|
||||
/// Get a sync rule for the given item
|
||||
SyncRuleItem? getSyncRule(String globalKey) => _syncRules[globalKey];
|
||||
|
||||
/// Create a sync rule for a show or season.
|
||||
Future<void> createSyncRule({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String targetType,
|
||||
required int episodeCount,
|
||||
int mediaIndex = 0,
|
||||
}) async {
|
||||
final globalKey = buildGlobalKey(serverId, ratingKey);
|
||||
await _database.insertSyncRule(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
targetType: targetType,
|
||||
episodeCount: episodeCount,
|
||||
mediaIndex: mediaIndex,
|
||||
);
|
||||
|
||||
// Reload to get the full row with id/timestamps
|
||||
final rule = await _database.getSyncRule(globalKey);
|
||||
if (rule != null) {
|
||||
_syncRules[globalKey] = rule;
|
||||
notifyListeners();
|
||||
}
|
||||
appLogger.i('Created sync rule: $globalKey ($targetType, keep $episodeCount)');
|
||||
}
|
||||
|
||||
/// Update the episode count for an existing sync rule.
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
||||
await _database.updateSyncRuleCount(globalKey, episodeCount);
|
||||
final existing = _syncRules[globalKey];
|
||||
if (existing != null) {
|
||||
_syncRules[globalKey] = existing.copyWith(episodeCount: episodeCount);
|
||||
notifyListeners();
|
||||
}
|
||||
appLogger.i('Updated sync rule $globalKey: keep $episodeCount');
|
||||
}
|
||||
|
||||
/// Toggle a sync rule's enabled state.
|
||||
Future<void> setSyncRuleEnabled(String globalKey, bool enabled) async {
|
||||
await _database.updateSyncRuleEnabled(globalKey, enabled);
|
||||
final existing = _syncRules[globalKey];
|
||||
if (existing != null) {
|
||||
_syncRules[globalKey] = existing.copyWith(enabled: enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
appLogger.i('${enabled ? 'Enabled' : 'Disabled'} sync rule: $globalKey');
|
||||
}
|
||||
|
||||
/// Delete a sync rule. Downloaded episodes are kept.
|
||||
Future<void> deleteSyncRule(String globalKey) async {
|
||||
await _database.deleteSyncRule(globalKey);
|
||||
_syncRules.remove(globalKey);
|
||||
notifyListeners();
|
||||
appLogger.i('Deleted sync rule: $globalKey');
|
||||
}
|
||||
|
||||
/// Execute all sync rules: auto-delete watched + queue replacements.
|
||||
///
|
||||
/// Returns titles of newly queued items (for snackbar display).
|
||||
Future<List<String>> executeSyncRules(MultiServerManager serverManager) async {
|
||||
if (_syncRules.isEmpty) return [];
|
||||
|
||||
final results = await _syncRuleExecutor.executeSyncRules(
|
||||
serverManager: serverManager,
|
||||
downloads: Map.unmodifiable(_downloads),
|
||||
metadata: Map.unmodifiable(_metadata),
|
||||
queueSingleDownload: (episode, client, {int mediaIndex = 0}) =>
|
||||
_queueSingleDownload(episode, client, mediaIndex: mediaIndex),
|
||||
);
|
||||
|
||||
return results.where((r) => r.queuedCount > 0).map((r) {
|
||||
final title = r.title ?? 'Unknown';
|
||||
return '$title (${r.queuedCount})';
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _loadSyncRules() async {
|
||||
try {
|
||||
_syncRules.clear();
|
||||
final rules = await _database.getSyncRules();
|
||||
for (final rule in rules) {
|
||||
_syncRules[rule.globalKey] = rule;
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to load sync rules', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown when download is blocked due to cellular-only setting
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../providers/download_provider.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
@@ -17,6 +18,7 @@ import '../../widgets/download_tree_view.dart';
|
||||
import '../main_screen.dart';
|
||||
import '../libraries/state_messages.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'sync_rules_screen.dart';
|
||||
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
@@ -30,6 +32,7 @@ class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderSta
|
||||
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
|
||||
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
|
||||
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
|
||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
|
||||
@override
|
||||
List<FocusNode> get tabChipFocusNodes => [_queueTabChipFocusNode, _tvShowsTabChipFocusNode, _moviesTabChipFocusNode];
|
||||
@@ -146,6 +149,20 @@ class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderSta
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
actions: [
|
||||
FocusableActionBar(
|
||||
key: _actionBarKey,
|
||||
onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(),
|
||||
onNavigateDown: _focusCurrentTab,
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.sync_rounded,
|
||||
tooltip: t.downloads.activeSyncRules,
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SyncRulesScreen())),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: Column(
|
||||
@@ -327,3 +344,4 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../database/app_database.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../providers/download_provider.dart';
|
||||
import '../../utils/download_utils.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../widgets/focusable_list_tile.dart';
|
||||
import '../libraries/state_messages.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
|
||||
class SyncRulesScreen extends StatelessWidget {
|
||||
const SyncRulesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<DownloadProvider>(
|
||||
builder: (context, downloadProvider, _) {
|
||||
final syncRules = downloadProvider.syncRules;
|
||||
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.downloads.activeSyncRules),
|
||||
slivers: [
|
||||
if (syncRules.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: EmptyStateWidget(
|
||||
message: t.downloads.noSyncRules,
|
||||
icon: Symbols.sync_rounded,
|
||||
iconSize: 80,
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final entry = syncRules.entries.elementAt(index);
|
||||
final rule = entry.value;
|
||||
return _SyncRuleTile(
|
||||
rule: rule,
|
||||
metadata: downloadProvider.metadata,
|
||||
downloadProvider: downloadProvider,
|
||||
autofocus: index == 0,
|
||||
);
|
||||
},
|
||||
childCount: syncRules.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncRuleTile extends StatelessWidget {
|
||||
final SyncRuleItem rule;
|
||||
final Map<String, PlexMetadata> metadata;
|
||||
final DownloadProvider downloadProvider;
|
||||
final bool autofocus;
|
||||
|
||||
const _SyncRuleTile({
|
||||
required this.rule,
|
||||
required this.metadata,
|
||||
required this.downloadProvider,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = metadata[rule.globalKey];
|
||||
final title = meta?.title ?? rule.ratingKey;
|
||||
|
||||
return FocusableListTile(
|
||||
autofocus: autofocus,
|
||||
leading: Icon(Symbols.sync_rounded, color: rule.enabled ? Colors.teal : null, size: 20),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(t.downloads.keepNUnwatched(count: rule.episodeCount.toString())),
|
||||
trailing: Switch(
|
||||
value: rule.enabled,
|
||||
onChanged: (value) => downloadProvider.setSyncRuleEnabled(rule.globalKey, value),
|
||||
),
|
||||
onTap: () => editSyncRuleCount(
|
||||
context,
|
||||
downloadProvider: downloadProvider,
|
||||
globalKey: rule.globalKey,
|
||||
currentCount: rule.episodeCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import '../widgets/plex_optimized_image.dart';
|
||||
import '../utils/plex_image_helper.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../services/plex_api_cache.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../utils/plex_cache_parser.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_role.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
@@ -62,6 +64,8 @@ import '../widgets/focusable_tab_chip.dart';
|
||||
import '../widgets/hub_section.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
|
||||
enum _SyncRuleAction { edit, remove, delete }
|
||||
|
||||
class MediaDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata metadata;
|
||||
final bool isOffline;
|
||||
@@ -720,7 +724,26 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// State 7: Partial Download (some episodes downloaded, not all)
|
||||
if (progress?.status == DownloadStatus.partial) {
|
||||
final hasSyncRule = downloadProvider.hasSyncRule(globalKey);
|
||||
final currentFile = progress?.currentFile;
|
||||
|
||||
if (hasSyncRule) {
|
||||
// Synced partial — this is the normal state for sync rules
|
||||
final syncRule = downloadProvider.getSyncRule(globalKey);
|
||||
final isEnabled = syncRule?.enabled ?? true;
|
||||
final tooltip = currentFile != null
|
||||
? '$currentFile (syncing ${t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?')})'
|
||||
: t.downloads.keepSynced;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => _showSyncRuleActions(context, downloadProvider, metadata, globalKey),
|
||||
tooltip: tooltip,
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
iconSize: 20,
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
final tooltip = currentFile != null
|
||||
? 'Downloaded $currentFile - Click to complete'
|
||||
: 'Partially downloaded - Click to complete';
|
||||
@@ -755,6 +778,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// State 8: Downloaded/Completed (can delete)
|
||||
if (downloadProvider.isDownloaded(globalKey)) {
|
||||
final hasSyncRule = downloadProvider.hasSyncRule(globalKey);
|
||||
|
||||
if (hasSyncRule) {
|
||||
// Synced + complete — show sync icon
|
||||
final syncRule = downloadProvider.getSyncRule(globalKey);
|
||||
final isEnabled = syncRule?.enabled ?? true;
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => _showSyncRuleActions(context, downloadProvider, metadata, globalKey),
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'),
|
||||
iconSize: 20,
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
// Show delete download confirmation
|
||||
@@ -785,18 +823,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
final count = await showDownloadOptionsAndQueue(
|
||||
final result = await showDownloadOptionsAndQueue(
|
||||
context,
|
||||
metadata: metadata,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
if (count == null || !context.mounted) return;
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final message = count > 1
|
||||
? t.downloads.episodesQueued(count: count)
|
||||
: t.downloads.downloadQueued;
|
||||
showSuccessSnackBar(context, message);
|
||||
showSuccessSnackBar(context, result.toSnackBarMessage());
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
@@ -829,7 +864,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
context,
|
||||
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
|
||||
);
|
||||
// Refresh offline OnDeck
|
||||
_updateWatchStateOffline();
|
||||
_loadOfflineOnDeckEpisode();
|
||||
}
|
||||
} else {
|
||||
@@ -838,15 +873,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (client == null) return;
|
||||
|
||||
if (isWatched) {
|
||||
await client.markAsUnwatched(metadata.ratingKey);
|
||||
await client.markAsUnwatched(metadata.ratingKey, metadata: metadata);
|
||||
} else {
|
||||
await client.markAsWatched(metadata.ratingKey);
|
||||
await client.markAsWatched(metadata.ratingKey, metadata: metadata);
|
||||
}
|
||||
if (mounted) {
|
||||
_watchStateChanged = true;
|
||||
showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched);
|
||||
// Update watch state without full rebuild
|
||||
_updateWatchState();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1039,13 +1072,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (client == null) return;
|
||||
final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale
|
||||
final success = await client.rateItem(metadata.ratingKey, plexRating);
|
||||
if (success) _updateWatchState();
|
||||
if (success) {
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = _fullMetadata?.copyWith(userRating: plexRating);
|
||||
});
|
||||
}
|
||||
},
|
||||
onClear: () async {
|
||||
final client = _getClientForMetadata(this.context);
|
||||
if (client == null) return;
|
||||
final success = await client.rateItem(metadata.ratingKey, -1);
|
||||
if (success) _updateWatchState();
|
||||
if (success) {
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = _fullMetadata?.copyWith(userRating: 0);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1119,6 +1160,67 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows actions for a synced item: edit count, remove rule, delete downloads.
|
||||
Future<void> _showSyncRuleActions(
|
||||
BuildContext context,
|
||||
DownloadProvider downloadProvider,
|
||||
PlexMetadata metadata,
|
||||
String globalKey,
|
||||
) async {
|
||||
final syncRule = downloadProvider.getSyncRule(globalKey);
|
||||
if (syncRule == null) return;
|
||||
|
||||
final selected = await showOptionPickerDialog<_SyncRuleAction>(
|
||||
context,
|
||||
title: t.downloads.manageSyncRule,
|
||||
options: [
|
||||
(icon: Symbols.edit_rounded, label: t.downloads.editSyncRule, value: _SyncRuleAction.edit),
|
||||
(icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule, value: _SyncRuleAction.remove),
|
||||
(icon: Symbols.delete_rounded, label: t.downloads.deleteDownload, value: _SyncRuleAction.delete),
|
||||
],
|
||||
);
|
||||
|
||||
if (selected == null || !context.mounted) return;
|
||||
|
||||
switch (selected) {
|
||||
case _SyncRuleAction.edit:
|
||||
final updated = await editSyncRuleCount(
|
||||
context,
|
||||
downloadProvider: downloadProvider,
|
||||
globalKey: globalKey,
|
||||
currentCount: syncRule.episodeCount,
|
||||
);
|
||||
if (updated && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleUpdated);
|
||||
}
|
||||
|
||||
case _SyncRuleAction.remove:
|
||||
final removed = await confirmAndRemoveSyncRule(
|
||||
context,
|
||||
downloadProvider: downloadProvider,
|
||||
globalKey: globalKey,
|
||||
displayTitle: metadata.displayTitle,
|
||||
);
|
||||
if (removed && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
||||
}
|
||||
|
||||
case _SyncRuleAction.delete:
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
if (confirmed && context.mounted) {
|
||||
await downloadProvider.deleteSyncRule(globalKey);
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFullMetadata() async {
|
||||
setState(() {
|
||||
_isLoadingMetadata = true;
|
||||
@@ -1691,7 +1793,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
item: season,
|
||||
onRefresh: (_) {
|
||||
_watchStateChanged = true;
|
||||
_updateWatchState();
|
||||
},
|
||||
onListRefresh: () {
|
||||
if (widget.isOffline) {
|
||||
@@ -2046,50 +2147,39 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Update watch state without full screen rebuild
|
||||
/// This preserves scroll position and only updates watch-related data
|
||||
Future<void> _updateWatchState() async {
|
||||
// Skip in offline mode
|
||||
if (widget.isOffline) return;
|
||||
|
||||
try {
|
||||
// Use server-specific client for this metadata
|
||||
final client = _getClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
/// Offline: update viewCount in the API cache and re-read metadata from it.
|
||||
Future<void> _updateWatchStateOffline() async {
|
||||
final serverId = widget.metadata.serverId;
|
||||
if (serverId == null) return;
|
||||
|
||||
final metadata = await client.getMetadataWithImages(widget.metadata.ratingKey);
|
||||
final ratingKey = widget.metadata.ratingKey;
|
||||
final cache = PlexApiCache.instance;
|
||||
final syncService = context.read<OfflineWatchSyncService>();
|
||||
|
||||
if (metadata != null) {
|
||||
// Preserve serverId from original metadata
|
||||
final metadataWithServerId = metadata.copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
final endpoint = '/library/metadata/$ratingKey';
|
||||
final cached = await cache.get(serverId, endpoint);
|
||||
final json = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (json == null) return;
|
||||
|
||||
// For shows, also refetch seasons to update their watch counts
|
||||
List<PlexMetadata>? updatedSeasons;
|
||||
if (metadata.isShow) {
|
||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
// Preserve serverId for each season
|
||||
updatedSeasons = seasons
|
||||
.map(
|
||||
(season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Single setState to minimize rebuilds - scroll position is preserved by controller
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = metadataWithServerId;
|
||||
if (updatedSeasons != null) {
|
||||
_seasons = updatedSeasons;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to update watch state', error: e);
|
||||
// Silently fail - user can manually refresh if needed
|
||||
final localStatus = await syncService.getLocalWatchStatus('$serverId:$ratingKey');
|
||||
if (localStatus == true) {
|
||||
json['viewCount'] = 1;
|
||||
} else if (localStatus == false) {
|
||||
json['viewCount'] = 0;
|
||||
json['viewOffset'] = 0;
|
||||
}
|
||||
|
||||
await cache.put(serverId, endpoint, {
|
||||
'MediaContainer': {'Metadata': [json]},
|
||||
});
|
||||
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = PlexMetadata.fromJsonWithImages(json).copyWith(
|
||||
serverId: widget.metadata.serverId,
|
||||
serverName: widget.metadata.serverName,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _playFirstEpisode() async {
|
||||
|
||||
@@ -46,6 +46,9 @@ class PlaybackProgressTracker {
|
||||
/// Counts timer ticks while paused to send periodic "paused" heartbeats.
|
||||
int _pausedTickCounter = 0;
|
||||
|
||||
/// Whether we've already scrobbled (marked as watched) for this playback session.
|
||||
bool _scrobbled = false;
|
||||
|
||||
PlaybackProgressTracker({
|
||||
required this.client,
|
||||
required this.metadata,
|
||||
@@ -56,10 +59,6 @@ class PlaybackProgressTracker {
|
||||
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
|
||||
assert(isOffline || client != null, 'client is required when isOffline is false');
|
||||
|
||||
/// Start tracking playback progress
|
||||
///
|
||||
/// Begins periodic timeline updates to the Plex server (online)
|
||||
/// or queuing progress updates locally (offline).
|
||||
void startTracking() {
|
||||
if (_progressTimer != null) {
|
||||
appLogger.w('Progress tracking already started');
|
||||
@@ -99,17 +98,12 @@ class PlaybackProgressTracker {
|
||||
appLogger.d('Started progress tracking (interval: ${updateInterval.inSeconds}s, offline: $isOffline)');
|
||||
}
|
||||
|
||||
/// Stop tracking playback progress
|
||||
///
|
||||
/// Cancels the periodic timer.
|
||||
void stopTracking() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
appLogger.d('Stopped progress tracking');
|
||||
}
|
||||
|
||||
/// Send progress update to Plex server or queue locally
|
||||
///
|
||||
/// [state] can be 'playing', 'paused', or 'stopped'
|
||||
Future<void> sendProgress(String state) async {
|
||||
await _sendProgress(state);
|
||||
@@ -150,12 +144,14 @@ class PlaybackProgressTracker {
|
||||
});
|
||||
}
|
||||
|
||||
// Emit watch state event on stop for UI updates across screens
|
||||
if (state == 'stopped' && position.inMilliseconds > 0) {
|
||||
// Emit watch state event on stop for UI updates across screens.
|
||||
// Skip if already scrobbled — markAsWatched already emitted a watched event.
|
||||
if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled) {
|
||||
WatchStateNotifier().notifyProgress(
|
||||
metadata: metadata,
|
||||
viewOffset: position.inMilliseconds,
|
||||
duration: duration.inMilliseconds,
|
||||
watchedThreshold: client != null ? client!.watchedThresholdPercent / 100.0 : 0.9,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -188,6 +184,24 @@ class PlaybackProgressTracker {
|
||||
state: state,
|
||||
duration: duration.inMilliseconds,
|
||||
);
|
||||
|
||||
// Explicitly scrobble once progress crosses the watched threshold.
|
||||
// The Plex server may not auto-mark from timeline updates alone
|
||||
// (e.g. when playing a local file without an active play session).
|
||||
if (!_scrobbled && duration.inMilliseconds > 0) {
|
||||
final percent = position.inMilliseconds / duration.inMilliseconds;
|
||||
final threshold = client!.watchedThresholdPercent / 100.0;
|
||||
if (percent >= threshold) {
|
||||
_scrobbled = true;
|
||||
try {
|
||||
await client!.markAsWatched(metadata.ratingKey, metadata: metadata);
|
||||
appLogger.d('Scrobbled ${metadata.ratingKey} (${(percent * 100).toStringAsFixed(0)}% >= ${client!.watchedThresholdPercent}%)');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to scrobble ${metadata.ratingKey}', error: e);
|
||||
_scrobbled = false; // Retry on next tick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue progress update locally (offline mode)
|
||||
@@ -211,7 +225,6 @@ class PlaybackProgressTracker {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
stopTracking();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import '../database/app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import 'download_manager_service.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
import 'plex_client.dart';
|
||||
|
||||
/// Result of executing a single sync rule.
|
||||
class SyncRuleResult {
|
||||
final String globalKey;
|
||||
final String? title;
|
||||
final int queuedCount;
|
||||
|
||||
const SyncRuleResult({required this.globalKey, this.title, required this.queuedCount});
|
||||
}
|
||||
|
||||
/// Evaluates sync rules and queues downloads to maintain the target episode count.
|
||||
///
|
||||
/// Each sync rule says "keep N unwatched episodes downloaded for show/season X".
|
||||
/// The executor counts how many unwatched episodes are already downloaded,
|
||||
/// calculates the deficit, and queues new episodes to fill the gap.
|
||||
class SyncRuleExecutor {
|
||||
final AppDatabase _database;
|
||||
bool _isExecuting = false;
|
||||
|
||||
SyncRuleExecutor({required AppDatabase database}) : _database = database;
|
||||
|
||||
bool get isExecuting => _isExecuting;
|
||||
|
||||
/// Execute all sync rules and return results for newly queued items.
|
||||
///
|
||||
/// [downloads] is the current download state map from DownloadProvider.
|
||||
/// [metadata] is the current metadata map from DownloadProvider.
|
||||
/// [queueSingleDownload] is a callback to queue a single episode via DownloadProvider.
|
||||
Future<List<SyncRuleResult>> executeSyncRules({
|
||||
required MultiServerManager serverManager,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
required Map<String, PlexMetadata> metadata,
|
||||
required Future<bool> Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload,
|
||||
}) async {
|
||||
if (_isExecuting) {
|
||||
appLogger.d('Sync rule execution already in progress, skipping');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Respect WiFi-only setting
|
||||
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
|
||||
appLogger.d('Skipping sync rules — cellular download blocked');
|
||||
return [];
|
||||
}
|
||||
|
||||
_isExecuting = true;
|
||||
try {
|
||||
final rules = await _database.getSyncRules();
|
||||
if (rules.isEmpty) return [];
|
||||
|
||||
appLogger.i('Executing ${rules.length} sync rules');
|
||||
final results = <SyncRuleResult>[];
|
||||
|
||||
for (final rule in rules) {
|
||||
if (!rule.enabled) continue;
|
||||
try {
|
||||
final result = await _executeRule(
|
||||
rule: rule,
|
||||
serverManager: serverManager,
|
||||
downloads: downloads,
|
||||
metadata: metadata,
|
||||
queueSingleDownload: queueSingleDownload,
|
||||
);
|
||||
if (result != null && result.queuedCount > 0) {
|
||||
results.add(result);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to execute sync rule ${rule.globalKey}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
_isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SyncRuleResult?> _executeRule({
|
||||
required SyncRuleItem rule,
|
||||
required MultiServerManager serverManager,
|
||||
required Map<String, DownloadProgress> downloads,
|
||||
required Map<String, PlexMetadata> metadata,
|
||||
required Future<bool> Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload,
|
||||
}) async {
|
||||
final client = serverManager.getClient(rule.serverId);
|
||||
if (client == null || !serverManager.isServerOnline(rule.serverId)) {
|
||||
appLogger.d('Skipping sync rule ${rule.globalKey} — server offline');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collect all unwatched episodes from server
|
||||
final unwatchedEpisodes = <PlexMetadata>[];
|
||||
if (rule.targetType == ContentTypes.show) {
|
||||
await _collectUnwatchedForShow(client, rule.serverId, rule.ratingKey, unwatchedEpisodes);
|
||||
} else {
|
||||
await _collectUnwatchedForSeason(client, rule.serverId, rule.ratingKey, unwatchedEpisodes);
|
||||
}
|
||||
|
||||
if (unwatchedEpisodes.isEmpty) {
|
||||
appLogger.d('Sync rule ${rule.globalKey}: no unwatched episodes available');
|
||||
await _database.updateSyncRuleLastExecuted(rule.globalKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
int alreadyHave = 0;
|
||||
for (final ep in unwatchedEpisodes) {
|
||||
final gk = buildGlobalKey(rule.serverId, ep.ratingKey);
|
||||
if (_isActiveDownload(downloads[gk])) alreadyHave++;
|
||||
}
|
||||
|
||||
// episodeCount == 0 means "all unwatched" — target is total unwatched count
|
||||
final targetCount = rule.episodeCount > 0 ? rule.episodeCount : unwatchedEpisodes.length;
|
||||
final deficit = targetCount - alreadyHave;
|
||||
if (deficit <= 0) {
|
||||
appLogger.d('Sync rule ${rule.globalKey}: no deficit ($alreadyHave/$targetCount already have)');
|
||||
await _database.updateSyncRuleLastExecuted(rule.globalKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
int queued = 0;
|
||||
for (final ep in unwatchedEpisodes) {
|
||||
if (queued >= deficit) break;
|
||||
|
||||
final gk = buildGlobalKey(rule.serverId, ep.ratingKey);
|
||||
if (_isActiveDownload(downloads[gk])) continue;
|
||||
|
||||
final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId);
|
||||
final ok = await queueSingleDownload(episodeWithServer, client, mediaIndex: rule.mediaIndex);
|
||||
if (ok) {
|
||||
queued++;
|
||||
appLogger.d('Sync rule ${rule.globalKey}: queued ${ep.title}');
|
||||
}
|
||||
}
|
||||
|
||||
await _database.updateSyncRuleLastExecuted(rule.globalKey);
|
||||
|
||||
// Get display title from metadata
|
||||
final displayTitle = metadata[rule.globalKey]?.title;
|
||||
appLogger.i('Sync rule ${rule.globalKey}: queued $queued episodes (had $alreadyHave/$targetCount)');
|
||||
|
||||
return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued);
|
||||
}
|
||||
|
||||
static bool _isActiveDownload(DownloadProgress? p) =>
|
||||
p != null &&
|
||||
(p.status == DownloadStatus.completed || p.status == DownloadStatus.downloading || p.status == DownloadStatus.queued);
|
||||
|
||||
Future<void> _collectUnwatchedForShow(
|
||||
PlexClient client,
|
||||
String serverId,
|
||||
String showRatingKey,
|
||||
List<PlexMetadata> out,
|
||||
) async {
|
||||
final seasons = await client.getChildren(showRatingKey);
|
||||
for (final season in seasons) {
|
||||
if (season.type == ContentTypes.season) {
|
||||
await _collectUnwatchedForSeason(client, serverId, season.ratingKey, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _collectUnwatchedForSeason(
|
||||
PlexClient client,
|
||||
String serverId,
|
||||
String seasonRatingKey,
|
||||
List<PlexMetadata> out,
|
||||
) async {
|
||||
final episodes = await client.getChildren(seasonRatingKey);
|
||||
for (final ep in episodes) {
|
||||
if (ep.type == 'episode' && !ep.isWatched && !ep.hasActiveProgress) {
|
||||
out.add(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,23 +79,11 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
|
||||
DeletionEvent(
|
||||
ratingKey: metadata.ratingKey,
|
||||
serverId: metadata.serverId ?? '',
|
||||
parentChain: _buildParentChain(metadata),
|
||||
parentChain: metadata.parentChain,
|
||||
mediaType: metadata.type ?? '',
|
||||
leafCount: metadata.leafCount ?? 1,
|
||||
isDownloadOnly: isDownloadOnly,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build parent chain from metadata's parent keys
|
||||
List<String> _buildParentChain(PlexMetadata metadata) {
|
||||
final chain = <String>[];
|
||||
if (metadata.parentRatingKey != null) {
|
||||
chain.add(metadata.parentRatingKey!);
|
||||
}
|
||||
if (metadata.grandparentRatingKey != null) {
|
||||
chain.add(metadata.grandparentRatingKey!);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,36 @@ import '../i18n/strings.g.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import 'content_utils.dart';
|
||||
import 'dialogs.dart';
|
||||
import 'download_version_utils.dart';
|
||||
import 'global_key_utils.dart';
|
||||
|
||||
/// Dialog option for the download picker. Typed to avoid stringly-typed values.
|
||||
enum _DownloadChoice { all, unwatched, next5, next10, custom }
|
||||
|
||||
/// Whether the user chose a one-time download or a persistent sync rule.
|
||||
enum _SyncChoice { downloadOnce, keepSynced }
|
||||
|
||||
/// Result of the download dialog + queue operation.
|
||||
class DownloadResult {
|
||||
final int count;
|
||||
final bool syncRuleCreated;
|
||||
final bool syncRuleUpdated;
|
||||
const DownloadResult({required this.count, this.syncRuleCreated = false, this.syncRuleUpdated = false});
|
||||
|
||||
String toSnackBarMessage() {
|
||||
if (syncRuleUpdated) return t.downloads.syncRuleUpdated;
|
||||
if (syncRuleCreated) return t.downloads.syncRuleCreated(count: count.toString());
|
||||
if (count > 1) return t.downloads.episodesQueued(count: count);
|
||||
return t.downloads.downloadQueued;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows download options dialog for shows/seasons, then queues the download.
|
||||
/// For movies/episodes, queues directly without a dialog.
|
||||
/// Returns the number of items queued, or null if cancelled.
|
||||
Future<int?> showDownloadOptionsAndQueue(
|
||||
/// Returns a [DownloadResult], or null if cancelled.
|
||||
Future<DownloadResult?> showDownloadOptionsAndQueue(
|
||||
BuildContext context, {
|
||||
required PlexMetadata metadata,
|
||||
required PlexClient client,
|
||||
@@ -24,6 +44,7 @@ Future<int?> showDownloadOptionsAndQueue(
|
||||
|
||||
var filter = DownloadFilter.all;
|
||||
int? maxCount;
|
||||
bool keepSynced = false;
|
||||
|
||||
if (mt == PlexMediaType.show || mt == PlexMediaType.season) {
|
||||
int? customCount;
|
||||
@@ -61,6 +82,20 @@ Future<int?> showDownloadOptionsAndQueue(
|
||||
filter = DownloadFilter.unwatched;
|
||||
maxCount = customCount;
|
||||
}
|
||||
|
||||
// For unwatched-based options on shows, offer sync vs one-time download
|
||||
if (filter == DownloadFilter.unwatched && mt == PlexMediaType.show && context.mounted) {
|
||||
final syncChoice = await showOptionPickerDialog<_SyncChoice>(
|
||||
context,
|
||||
title: t.downloads.downloadNow,
|
||||
options: [
|
||||
(icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce),
|
||||
(icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced),
|
||||
],
|
||||
);
|
||||
if (syncChoice == null || !context.mounted) return null;
|
||||
keepSynced = syncChoice == _SyncChoice.keepSynced;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return null;
|
||||
@@ -68,13 +103,35 @@ Future<int?> showDownloadOptionsAndQueue(
|
||||
final versionConfig = await resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return null;
|
||||
|
||||
return await downloadProvider.queueDownload(
|
||||
// Create or update sync rule before queueing (so the rule exists even if queue fails)
|
||||
bool syncRuleUpdated = false;
|
||||
if (keepSynced) {
|
||||
final globalKey = buildGlobalKey(metadata.serverId ?? client.serverId, metadata.ratingKey);
|
||||
syncRuleUpdated = downloadProvider.hasSyncRule(globalKey);
|
||||
|
||||
final syncCount = maxCount ?? 0; // 0 means "all unwatched" for the rule
|
||||
await downloadProvider.createSyncRule(
|
||||
serverId: metadata.serverId ?? client.serverId,
|
||||
ratingKey: metadata.ratingKey,
|
||||
targetType: metadata.type ?? ContentTypes.show,
|
||||
episodeCount: syncCount,
|
||||
mediaIndex: versionConfig.mediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
final count = await downloadProvider.queueDownload(
|
||||
metadata,
|
||||
client,
|
||||
versionConfig: versionConfig,
|
||||
filter: filter,
|
||||
maxCount: maxCount,
|
||||
);
|
||||
|
||||
return DownloadResult(
|
||||
count: count,
|
||||
syncRuleCreated: keepSynced && !syncRuleUpdated,
|
||||
syncRuleUpdated: syncRuleUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows download options dialog for playlists, then queues the download.
|
||||
@@ -103,12 +160,12 @@ Future<int?> showPlaylistDownloadOptionsAndQueue(
|
||||
);
|
||||
}
|
||||
|
||||
Future<int?> _showEpisodeCountDialog(BuildContext context) async {
|
||||
Future<int?> _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async {
|
||||
final result = await showTextInputDialog(
|
||||
context,
|
||||
title: t.downloads.howManyEpisodes,
|
||||
title: title ?? t.downloads.howManyEpisodes,
|
||||
labelText: '',
|
||||
hintText: '',
|
||||
hintText: hintText ?? '',
|
||||
confirmText: t.common.ok,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
@@ -121,3 +178,40 @@ Future<int?> _showEpisodeCountDialog(BuildContext context) async {
|
||||
if (result == null) return null;
|
||||
return int.tryParse(result);
|
||||
}
|
||||
|
||||
/// Shows a dialog to edit a sync rule's episode count. Returns true if updated.
|
||||
Future<bool> editSyncRuleCount(
|
||||
BuildContext context, {
|
||||
required DownloadProvider downloadProvider,
|
||||
required String globalKey,
|
||||
required int currentCount,
|
||||
}) async {
|
||||
final count = await _showEpisodeCountDialog(
|
||||
context,
|
||||
title: t.downloads.editEpisodeCount,
|
||||
hintText: currentCount.toString(),
|
||||
);
|
||||
if (count == null || !context.mounted) return false;
|
||||
|
||||
await downloadProvider.updateSyncRuleCount(globalKey, count);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog to remove a sync rule. Returns true if removed.
|
||||
Future<bool> confirmAndRemoveSyncRule(
|
||||
BuildContext context, {
|
||||
required DownloadProvider downloadProvider,
|
||||
required String globalKey,
|
||||
required String displayTitle,
|
||||
}) async {
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.downloads.removeSyncRule,
|
||||
message: t.downloads.removeSyncRuleConfirm(title: displayTitle),
|
||||
confirmText: t.downloads.removeSyncRule,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return false;
|
||||
|
||||
await downloadProvider.deleteSyncRule(globalKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -85,40 +85,34 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
|
||||
ratingKey: metadata.ratingKey,
|
||||
serverId: metadata.serverId ?? '',
|
||||
changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched,
|
||||
parentChain: _buildParentChain(metadata),
|
||||
parentChain: metadata.parentChain,
|
||||
mediaType: metadata.type ?? '',
|
||||
isNowWatched: isNowWatched,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to emit a progress update event
|
||||
void notifyProgress({required PlexMetadata metadata, required int viewOffset, required int duration}) {
|
||||
const threshold = 0.9;
|
||||
final isNowWatched = duration > 0 && (viewOffset / duration) >= threshold;
|
||||
/// Helper to emit a progress update event.
|
||||
/// [watchedThreshold] defaults to 0.9 — pass the server's configured value
|
||||
/// (`client.watchedThresholdPercent / 100.0`) when available.
|
||||
void notifyProgress({
|
||||
required PlexMetadata metadata,
|
||||
required int viewOffset,
|
||||
required int duration,
|
||||
double watchedThreshold = 0.9,
|
||||
}) {
|
||||
final isNowWatched = duration > 0 && (viewOffset / duration) >= watchedThreshold;
|
||||
|
||||
notify(
|
||||
WatchStateEvent(
|
||||
ratingKey: metadata.ratingKey,
|
||||
serverId: metadata.serverId ?? '',
|
||||
changeType: WatchStateChangeType.progressUpdate,
|
||||
parentChain: _buildParentChain(metadata),
|
||||
parentChain: metadata.parentChain,
|
||||
mediaType: metadata.type ?? '',
|
||||
viewOffset: viewOffset,
|
||||
isNowWatched: isNowWatched,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build parent chain from metadata's parent keys
|
||||
List<String> _buildParentChain(PlexMetadata metadata) {
|
||||
final chain = <String>[];
|
||||
if (metadata.parentRatingKey != null) {
|
||||
chain.add(metadata.parentRatingKey!);
|
||||
}
|
||||
if (metadata.grandparentRatingKey != null) {
|
||||
chain.add(metadata.grandparentRatingKey!);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,12 +858,7 @@ class _MediaCardHelpers {
|
||||
),
|
||||
),
|
||||
// Progress bar for seasons (viewedLeafCount / leafCount)
|
||||
if (metadata.isSeason &&
|
||||
metadata.viewedLeafCount != null &&
|
||||
metadata.leafCount != null &&
|
||||
metadata.leafCount! > 0 &&
|
||||
metadata.viewedLeafCount! > 0 &&
|
||||
metadata.viewedLeafCount! < metadata.leafCount!)
|
||||
if (metadata.isSeason && metadata.isPartiallyWatched)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
|
||||
@@ -126,12 +126,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final mediaType = isPlaylist ? null : metadata!.mediaType;
|
||||
final isCollection = mediaType == PlexMediaType.collection;
|
||||
|
||||
final isPartiallyWatched =
|
||||
!isPlaylist &&
|
||||
metadata!.viewedLeafCount != null &&
|
||||
metadata.leafCount != null &&
|
||||
metadata.viewedLeafCount! > 0 &&
|
||||
metadata.viewedLeafCount! < metadata.leafCount!;
|
||||
final isPartiallyWatched = !isPlaylist && metadata!.isPartiallyWatched;
|
||||
|
||||
final hasActiveProgress =
|
||||
mediaType != null &&
|
||||
@@ -302,8 +297,23 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||
final globalKey = metadata.globalKey;
|
||||
final isDownloaded = downloadProvider.isDownloaded(globalKey);
|
||||
final hasSyncRule = downloadProvider.hasSyncRule(globalKey);
|
||||
final hasAnyDownload = downloadProvider.getProgress(globalKey) != null;
|
||||
|
||||
if (isDownloaded) {
|
||||
if (hasSyncRule) {
|
||||
// Synced item: manage sync + delete options
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule),
|
||||
);
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule),
|
||||
);
|
||||
if (hasAnyDownload) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload),
|
||||
);
|
||||
}
|
||||
} else if (isDownloaded) {
|
||||
// Show delete download option
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload),
|
||||
@@ -551,6 +561,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _handleDeleteDownload(context);
|
||||
break;
|
||||
|
||||
case 'manage_sync':
|
||||
await _handleManageSyncRule(context);
|
||||
break;
|
||||
|
||||
case 'remove_sync':
|
||||
await _handleRemoveSyncRule(context);
|
||||
break;
|
||||
|
||||
case 'delete_media':
|
||||
await _handleDeleteMediaItem(context, mediaType);
|
||||
break;
|
||||
@@ -1157,16 +1175,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final client = _getClientForItem();
|
||||
|
||||
try {
|
||||
final count = await showDownloadOptionsAndQueue(
|
||||
final result = await showDownloadOptionsAndQueue(
|
||||
context,
|
||||
metadata: metadata,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
if (count == null || !context.mounted) return;
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued;
|
||||
showSuccessSnackBar(context, message);
|
||||
showSuccessSnackBar(context, result.toSnackBarMessage());
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
@@ -1213,6 +1230,38 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleManageSyncRule(BuildContext context) async {
|
||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
final syncRule = downloadProvider.getSyncRule(metadata.globalKey);
|
||||
if (syncRule == null) return;
|
||||
|
||||
final updated = await editSyncRuleCount(
|
||||
context,
|
||||
downloadProvider: downloadProvider,
|
||||
globalKey: metadata.globalKey,
|
||||
currentCount: syncRule.episodeCount,
|
||||
);
|
||||
if (updated && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoveSyncRule(BuildContext context) async {
|
||||
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
|
||||
final metadata = widget.item as PlexMetadata;
|
||||
|
||||
final removed = await confirmAndRemoveSyncRule(
|
||||
context,
|
||||
downloadProvider: downloadProvider,
|
||||
globalKey: metadata.globalKey,
|
||||
displayTitle: metadata.displayTitle,
|
||||
);
|
||||
if (removed && context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.syncRuleRemoved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle delete media item action
|
||||
/// This permanently removes the media item and its associated files from the server
|
||||
Future<void> _handleDeleteMediaItem(BuildContext context, PlexMediaType? mediaType) async {
|
||||
|
||||
Reference in New Issue
Block a user