feat: sync rules for downloads

close #867
This commit is contained in:
edde746
2026-04-16 12:52:48 +02:00
parent c369f2c3d7
commit 360741674d
47 changed files with 2546 additions and 154 deletions
+69 -2
View File
@@ -12,12 +12,12 @@ import '../utils/global_key_utils.dart';
part 'app_database.g.dart'; part 'app_database.g.dart';
// Simplified database with API cache for offline support // 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 { class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 9; // Added mediaIndex column to DownloadedMedia int get schemaVersion => 11;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -46,6 +46,18 @@ class AppDatabase extends _$AppDatabase {
appLogger.w('mediaIndex column may already exist: $e'); 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(); 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 // Downloaded Media Queries for Watch State Sync
// ============================================================ // ============================================================
+916
View File
@@ -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 { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this); $AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -2521,6 +3136,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $ApiCacheTable apiCache = $ApiCacheTable(this); late final $ApiCacheTable apiCache = $ApiCacheTable(this);
late final $OfflineWatchProgressTable offlineWatchProgress = late final $OfflineWatchProgressTable offlineWatchProgress =
$OfflineWatchProgressTable(this); $OfflineWatchProgressTable(this);
late final $SyncRulesTable syncRules = $SyncRulesTable(this);
@override @override
Iterable<TableInfo<Table, Object?>> get allTables => Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>(); allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -2530,6 +3146,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
downloadQueue, downloadQueue,
apiCache, apiCache,
offlineWatchProgress, offlineWatchProgress,
syncRules,
]; ];
} }
@@ -3757,6 +4374,303 @@ typedef $$OfflineWatchProgressTableProcessedTableManager =
OfflineWatchProgressItem, OfflineWatchProgressItem,
PrefetchHooks Function() 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 { class $AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
@@ -3769,4 +4683,6 @@ class $AppDatabaseManager {
$$ApiCacheTableTableManager(_db, _db.apiCache); $$ApiCacheTableTableManager(_db, _db.apiCache);
$$OfflineWatchProgressTableTableManager get offlineWatchProgress => $$OfflineWatchProgressTableTableManager get offlineWatchProgress =>
$$OfflineWatchProgressTableTableManager(_db, _db.offlineWatchProgress); $$OfflineWatchProgressTableTableManager(_db, _db.offlineWatchProgress);
$$SyncRulesTableTableManager get syncRules =>
$$SyncRulesTableTableManager(_db, _db.syncRules);
} }
+18
View File
@@ -51,6 +51,24 @@ class DownloadedMedia extends Table {
IntColumn get mediaIndex => integer().withDefault(const Constant(0))(); 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. /// Queue for offline watch progress and manual watch actions.
/// ///
/// Stores watch progress updates and manual watch/unwatch actions /// Stores watch progress updates and manual watch/unwatch actions
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Næste ${count} usete", "nextNUnwatched": "Næste ${count} usete",
"customAmount": "Angiv antal...", "customAmount": "Angiv antal...",
"howManyEpisodes": "Hvor mange episoder?", "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": { "shaders": {
"title": "Shadere", "title": "Shadere",
+16 -1
View File
@@ -647,7 +647,22 @@
"nextNUnwatched": "Nächste ${count} ungesehene", "nextNUnwatched": "Nächste ${count} ungesehene",
"customAmount": "Eigene Anzahl...", "customAmount": "Eigene Anzahl...",
"howManyEpisodes": "Wie viele Episoden?", "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": { "playlists": {
"title": "Wiedergabelisten", "title": "Wiedergabelisten",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Next ${count} unwatched", "nextNUnwatched": "Next ${count} unwatched",
"customAmount": "Custom amount...", "customAmount": "Custom amount...",
"howManyEpisodes": "How many episodes?", "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": { "shaders": {
"title": "Shaders", "title": "Shaders",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Próximos ${count} no vistos", "nextNUnwatched": "Próximos ${count} no vistos",
"customAmount": "Cantidad personalizada...", "customAmount": "Cantidad personalizada...",
"howManyEpisodes": "¿Cuántos episodios?", "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": { "shaders": {
"title": "Shaders", "title": "Shaders",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "${count} prochains non vus", "nextNUnwatched": "${count} prochains non vus",
"customAmount": "Quantité personnalisée...", "customAmount": "Quantité personnalisée...",
"howManyEpisodes": "Combien d'épisodes ?", "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": { "shaders": {
"title": "Shaders", "title": "Shaders",
+16 -1
View File
@@ -647,7 +647,22 @@
"nextNUnwatched": "Prossimi ${count} non visti", "nextNUnwatched": "Prossimi ${count} non visti",
"customAmount": "Quantità personalizzata...", "customAmount": "Quantità personalizzata...",
"howManyEpisodes": "Quanti episodi?", "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": { "playlists": {
"title": "Playlist", "title": "Playlist",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "次の${count}件の未視聴", "nextNUnwatched": "次の${count}件の未視聴",
"customAmount": "数を指定...", "customAmount": "数を指定...",
"howManyEpisodes": "何エピソード?", "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": { "shaders": {
"title": "シェーダー", "title": "シェーダー",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "다음 ${count}개 미시청", "nextNUnwatched": "다음 ${count}개 미시청",
"customAmount": "직접 입력...", "customAmount": "직접 입력...",
"howManyEpisodes": "몇 개의 에피소드?", "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": { "shaders": {
"title": "셰이더", "title": "셰이더",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Neste ${count} usette", "nextNUnwatched": "Neste ${count} usette",
"customAmount": "Egendefinert antall...", "customAmount": "Egendefinert antall...",
"howManyEpisodes": "Hvor mange episoder?", "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": { "shaders": {
"title": "Shadere", "title": "Shadere",
+16 -1
View File
@@ -647,7 +647,22 @@
"nextNUnwatched": "Volgende ${count} onbekeken", "nextNUnwatched": "Volgende ${count} onbekeken",
"customAmount": "Aangepast aantal...", "customAmount": "Aangepast aantal...",
"howManyEpisodes": "Hoeveel afleveringen?", "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": { "playlists": {
"title": "Afspeellijsten", "title": "Afspeellijsten",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Następne ${count} nieobejrzanych", "nextNUnwatched": "Następne ${count} nieobejrzanych",
"customAmount": "Własna ilość...", "customAmount": "Własna ilość...",
"howManyEpisodes": "Ile odcinków?", "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": { "shaders": {
"title": "Shadery", "title": "Shadery",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Próximos ${count} não assistidos", "nextNUnwatched": "Próximos ${count} não assistidos",
"customAmount": "Quantidade personalizada...", "customAmount": "Quantidade personalizada...",
"howManyEpisodes": "Quantos episódios?", "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": { "shaders": {
"title": "Shaders", "title": "Shaders",
+16 -1
View File
@@ -758,7 +758,22 @@
"nextNUnwatched": "Следующие ${count} непросмотренных", "nextNUnwatched": "Следующие ${count} непросмотренных",
"customAmount": "Указать количество...", "customAmount": "Указать количество...",
"howManyEpisodes": "Сколько эпизодов?", "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": { "shaders": {
"title": "Шейдеры", "title": "Шейдеры",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 15 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn {
@override String get customAmount => 'Angiv antal...'; @override String get customAmount => 'Angiv antal...';
@override String get howManyEpisodes => 'Hvor mange episoder?'; @override String get howManyEpisodes => 'Hvor mange episoder?';
@override String itemsQueued({required Object count}) => '${count} elementer sat i kø til download'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsDa {
'downloads.customAmount' => 'Angiv antal...', 'downloads.customAmount' => 'Angiv antal...',
'downloads.howManyEpisodes' => 'Hvor mange episoder?', 'downloads.howManyEpisodes' => 'Hvor mange episoder?',
'downloads.itemsQueued' => ({required Object count}) => '${count} elementer sat i kø til download', '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.title' => 'Shadere',
'shaders.noShaderDescription' => 'Ingen videoforbedring', 'shaders.noShaderDescription' => 'Ingen videoforbedring',
'shaders.nvscalerDescription' => 'NVIDIA-billedskalering for skarpere video', 'shaders.nvscalerDescription' => 'NVIDIA-billedskalering for skarpere video',
+30
View File
@@ -887,6 +887,21 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn {
@override String get customAmount => 'Eigene Anzahl...'; @override String get customAmount => 'Eigene Anzahl...';
@override String get howManyEpisodes => 'Wie viele Episoden?'; @override String get howManyEpisodes => 'Wie viele Episoden?';
@override String itemsQueued({required Object count}) => '${count} Elemente zum Download eingereiht'; @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 // Path: playlists
@@ -1950,6 +1965,21 @@ extension on TranslationsDe {
'downloads.customAmount' => 'Eigene Anzahl...', 'downloads.customAmount' => 'Eigene Anzahl...',
'downloads.howManyEpisodes' => 'Wie viele Episoden?', 'downloads.howManyEpisodes' => 'Wie viele Episoden?',
'downloads.itemsQueued' => ({required Object count}) => '${count} Elemente zum Download eingereiht', '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.title' => 'Wiedergabelisten',
'playlists.noPlaylists' => 'Keine Wiedergabelisten gefunden', 'playlists.noPlaylists' => 'Keine Wiedergabelisten gefunden',
'playlists.create' => 'Wiedergabeliste erstellen', 'playlists.create' => 'Wiedergabeliste erstellen',
+60
View File
@@ -2329,6 +2329,51 @@ class TranslationsDownloadsEn {
/// en: '${count} items queued for download' /// en: '${count} items queued for download'
String itemsQueued({required Object count}) => '${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 // Path: shaders
@@ -3796,6 +3841,21 @@ extension on Translations {
'downloads.customAmount' => 'Custom amount...', 'downloads.customAmount' => 'Custom amount...',
'downloads.howManyEpisodes' => 'How many episodes?', 'downloads.howManyEpisodes' => 'How many episodes?',
'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download', '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.title' => 'Shaders',
'shaders.noShaderDescription' => 'No video enhancement', 'shaders.noShaderDescription' => 'No video enhancement',
'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video', 'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn {
@override String get customAmount => 'Cantidad personalizada...'; @override String get customAmount => 'Cantidad personalizada...';
@override String get howManyEpisodes => '¿Cuántos episodios?'; @override String get howManyEpisodes => '¿Cuántos episodios?';
@override String itemsQueued({required Object count}) => '${count} elementos en cola de descarga'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsEs {
'downloads.customAmount' => 'Cantidad personalizada...', 'downloads.customAmount' => 'Cantidad personalizada...',
'downloads.howManyEpisodes' => '¿Cuántos episodios?', 'downloads.howManyEpisodes' => '¿Cuántos episodios?',
'downloads.itemsQueued' => ({required Object count}) => '${count} elementos en cola de descarga', '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.title' => 'Shaders',
'shaders.noShaderDescription' => 'Sin mejora de video', 'shaders.noShaderDescription' => 'Sin mejora de video',
'shaders.nvscalerDescription' => 'Escalado de imagen NVIDIA para un video más nítido', 'shaders.nvscalerDescription' => 'Escalado de imagen NVIDIA para un video más nítido',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn {
@override String get customAmount => 'Quantité personnalisée...'; @override String get customAmount => 'Quantité personnalisée...';
@override String get howManyEpisodes => 'Combien d\'épisodes ?'; @override String get howManyEpisodes => 'Combien d\'épisodes ?';
@override String itemsQueued({required Object count}) => '${count} éléments mis en file d\'attente'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsFr {
'downloads.customAmount' => 'Quantité personnalisée...', 'downloads.customAmount' => 'Quantité personnalisée...',
'downloads.howManyEpisodes' => 'Combien d\'épisodes ?', 'downloads.howManyEpisodes' => 'Combien d\'épisodes ?',
'downloads.itemsQueued' => ({required Object count}) => '${count} éléments mis en file d\'attente', '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.title' => 'Shaders',
'shaders.noShaderDescription' => 'Aucune amélioration vidéo', 'shaders.noShaderDescription' => 'Aucune amélioration vidéo',
'shaders.nvscalerDescription' => 'Mise à l\'échelle NVIDIA pour une vidéo plus nette', 'shaders.nvscalerDescription' => 'Mise à l\'échelle NVIDIA pour une vidéo plus nette',
+30
View File
@@ -887,6 +887,21 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn {
@override String get customAmount => 'Quantità personalizzata...'; @override String get customAmount => 'Quantità personalizzata...';
@override String get howManyEpisodes => 'Quanti episodi?'; @override String get howManyEpisodes => 'Quanti episodi?';
@override String itemsQueued({required Object count}) => '${count} elementi in coda per il download'; @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 // Path: playlists
@@ -1950,6 +1965,21 @@ extension on TranslationsIt {
'downloads.customAmount' => 'Quantità personalizzata...', 'downloads.customAmount' => 'Quantità personalizzata...',
'downloads.howManyEpisodes' => 'Quanti episodi?', 'downloads.howManyEpisodes' => 'Quanti episodi?',
'downloads.itemsQueued' => ({required Object count}) => '${count} elementi in coda per il download', '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.title' => 'Playlist',
'playlists.noPlaylists' => 'Nessuna playlist trovata', 'playlists.noPlaylists' => 'Nessuna playlist trovata',
'playlists.create' => 'Crea playlist', 'playlists.create' => 'Crea playlist',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn {
@override String get customAmount => '数を指定...'; @override String get customAmount => '数を指定...';
@override String get howManyEpisodes => '何エピソード?'; @override String get howManyEpisodes => '何エピソード?';
@override String itemsQueued({required Object count}) => '${count}件をダウンロードキューに追加'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsJa {
'downloads.customAmount' => '数を指定...', 'downloads.customAmount' => '数を指定...',
'downloads.howManyEpisodes' => '何エピソード?', 'downloads.howManyEpisodes' => '何エピソード?',
'downloads.itemsQueued' => ({required Object count}) => '${count}件をダウンロードキューに追加', '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.title' => 'シェーダー',
'shaders.noShaderDescription' => '映像補正なし', 'shaders.noShaderDescription' => '映像補正なし',
'shaders.nvscalerDescription' => 'よりシャープな映像のためのNVIDIA画像スケーリング', 'shaders.nvscalerDescription' => 'よりシャープな映像のためのNVIDIA画像スケーリング',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn {
@override String get customAmount => '직접 입력...'; @override String get customAmount => '직접 입력...';
@override String get howManyEpisodes => '몇 개의 에피소드?'; @override String get howManyEpisodes => '몇 개의 에피소드?';
@override String itemsQueued({required Object count}) => '${count}개 항목이 다운로드 대기열에 추가됨'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsKo {
'downloads.customAmount' => '직접 입력...', 'downloads.customAmount' => '직접 입력...',
'downloads.howManyEpisodes' => '몇 개의 에피소드?', 'downloads.howManyEpisodes' => '몇 개의 에피소드?',
'downloads.itemsQueued' => ({required Object count}) => '${count}개 항목이 다운로드 대기열에 추가됨', '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.title' => '셰이더',
'shaders.noShaderDescription' => '비디오 향상 없음', 'shaders.noShaderDescription' => '비디오 향상 없음',
'shaders.nvscalerDescription' => '더 선명한 비디오를 위한 NVIDIA 이미지 스케일링', 'shaders.nvscalerDescription' => '더 선명한 비디오를 위한 NVIDIA 이미지 스케일링',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn {
@override String get customAmount => 'Egendefinert antall...'; @override String get customAmount => 'Egendefinert antall...';
@override String get howManyEpisodes => 'Hvor mange episoder?'; @override String get howManyEpisodes => 'Hvor mange episoder?';
@override String itemsQueued({required Object count}) => '${count} elementer i nedlastingskø'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsNb {
'downloads.customAmount' => 'Egendefinert antall...', 'downloads.customAmount' => 'Egendefinert antall...',
'downloads.howManyEpisodes' => 'Hvor mange episoder?', 'downloads.howManyEpisodes' => 'Hvor mange episoder?',
'downloads.itemsQueued' => ({required Object count}) => '${count} elementer i nedlastingskø', '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.title' => 'Shadere',
'shaders.noShaderDescription' => 'Ingen videoforbedring', 'shaders.noShaderDescription' => 'Ingen videoforbedring',
'shaders.nvscalerDescription' => 'NVIDIA bildeskalering for skarpere video', 'shaders.nvscalerDescription' => 'NVIDIA bildeskalering for skarpere video',
+30
View File
@@ -887,6 +887,21 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn {
@override String get customAmount => 'Aangepast aantal...'; @override String get customAmount => 'Aangepast aantal...';
@override String get howManyEpisodes => 'Hoeveel afleveringen?'; @override String get howManyEpisodes => 'Hoeveel afleveringen?';
@override String itemsQueued({required Object count}) => '${count} items in downloadwachtrij'; @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 // Path: playlists
@@ -1950,6 +1965,21 @@ extension on TranslationsNl {
'downloads.customAmount' => 'Aangepast aantal...', 'downloads.customAmount' => 'Aangepast aantal...',
'downloads.howManyEpisodes' => 'Hoeveel afleveringen?', 'downloads.howManyEpisodes' => 'Hoeveel afleveringen?',
'downloads.itemsQueued' => ({required Object count}) => '${count} items in downloadwachtrij', '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.title' => 'Afspeellijsten',
'playlists.noPlaylists' => 'Geen afspeellijsten gevonden', 'playlists.noPlaylists' => 'Geen afspeellijsten gevonden',
'playlists.create' => 'Afspeellijst maken', 'playlists.create' => 'Afspeellijst maken',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn {
@override String get customAmount => 'Własna ilość...'; @override String get customAmount => 'Własna ilość...';
@override String get howManyEpisodes => 'Ile odcinków?'; @override String get howManyEpisodes => 'Ile odcinków?';
@override String itemsQueued({required Object count}) => '${count} elementów dodanych do kolejki pobierania'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsPl {
'downloads.customAmount' => 'Własna ilość...', 'downloads.customAmount' => 'Własna ilość...',
'downloads.howManyEpisodes' => 'Ile odcinków?', 'downloads.howManyEpisodes' => 'Ile odcinków?',
'downloads.itemsQueued' => ({required Object count}) => '${count} elementów dodanych do kolejki pobierania', '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.title' => 'Shadery',
'shaders.noShaderDescription' => 'Bez ulepszenia wideo', 'shaders.noShaderDescription' => 'Bez ulepszenia wideo',
'shaders.nvscalerDescription' => 'Skalowanie obrazu NVIDIA dla ostrzejszego wideo', 'shaders.nvscalerDescription' => 'Skalowanie obrazu NVIDIA dla ostrzejszego wideo',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn {
@override String get customAmount => 'Quantidade personalizada...'; @override String get customAmount => 'Quantidade personalizada...';
@override String get howManyEpisodes => 'Quantos episódios?'; @override String get howManyEpisodes => 'Quantos episódios?';
@override String itemsQueued({required Object count}) => '${count} itens na fila de download'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsPt {
'downloads.customAmount' => 'Quantidade personalizada...', 'downloads.customAmount' => 'Quantidade personalizada...',
'downloads.howManyEpisodes' => 'Quantos episódios?', 'downloads.howManyEpisodes' => 'Quantos episódios?',
'downloads.itemsQueued' => ({required Object count}) => '${count} itens na fila de download', '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.title' => 'Shaders',
'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo', 'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo',
'shaders.nvscalerDescription' => 'Escalonamento de imagem NVIDIA para vídeo mais nítido', 'shaders.nvscalerDescription' => 'Escalonamento de imagem NVIDIA para vídeo mais nítido',
+30
View File
@@ -1019,6 +1019,21 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn {
@override String get customAmount => 'Указать количество...'; @override String get customAmount => 'Указать количество...';
@override String get howManyEpisodes => 'Сколько эпизодов?'; @override String get howManyEpisodes => 'Сколько эпизодов?';
@override String itemsQueued({required Object count}) => '${count} элементов добавлено в очередь загрузки'; @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 // Path: shaders
@@ -2055,6 +2070,21 @@ extension on TranslationsRu {
'downloads.customAmount' => 'Указать количество...', 'downloads.customAmount' => 'Указать количество...',
'downloads.howManyEpisodes' => 'Сколько эпизодов?', 'downloads.howManyEpisodes' => 'Сколько эпизодов?',
'downloads.itemsQueued' => ({required Object count}) => '${count} элементов добавлено в очередь загрузки', '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.title' => 'Шейдеры',
'shaders.noShaderDescription' => 'Без улучшения видео', 'shaders.noShaderDescription' => 'Без улучшения видео',
'shaders.nvscalerDescription' => 'Масштабирование NVIDIA для более чёткого видео', 'shaders.nvscalerDescription' => 'Масштабирование NVIDIA для более чёткого видео',
+30
View File
@@ -887,6 +887,21 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn {
@override String get customAmount => 'Ange antal...'; @override String get customAmount => 'Ange antal...';
@override String get howManyEpisodes => 'Hur många avsnitt?'; @override String get howManyEpisodes => 'Hur många avsnitt?';
@override String itemsQueued({required Object count}) => '${count} objekt köade för nedladdning'; @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 // Path: playlists
@@ -1950,6 +1965,21 @@ extension on TranslationsSv {
'downloads.customAmount' => 'Ange antal...', 'downloads.customAmount' => 'Ange antal...',
'downloads.howManyEpisodes' => 'Hur många avsnitt?', 'downloads.howManyEpisodes' => 'Hur många avsnitt?',
'downloads.itemsQueued' => ({required Object count}) => '${count} objekt köade för nedladdning', '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.title' => 'Spellistor',
'playlists.noPlaylists' => 'Inga spellistor hittades', 'playlists.noPlaylists' => 'Inga spellistor hittades',
'playlists.create' => 'Skapa spellista', 'playlists.create' => 'Skapa spellista',
+30
View File
@@ -887,6 +887,21 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn {
@override String get customAmount => '自定义数量...'; @override String get customAmount => '自定义数量...';
@override String get howManyEpisodes => '下载几集?'; @override String get howManyEpisodes => '下载几集?';
@override String itemsQueued({required Object count}) => '${count} 个项目已加入下载队列'; @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 // Path: playlists
@@ -1950,6 +1965,21 @@ extension on TranslationsZh {
'downloads.customAmount' => '自定义数量...', 'downloads.customAmount' => '自定义数量...',
'downloads.howManyEpisodes' => '下载几集?', 'downloads.howManyEpisodes' => '下载几集?',
'downloads.itemsQueued' => ({required Object count}) => '${count} 个项目已加入下载队列', '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.title' => '播放列表',
'playlists.noPlaylists' => '未找到播放列表', 'playlists.noPlaylists' => '未找到播放列表',
'playlists.create' => '创建播放列表', 'playlists.create' => '创建播放列表',
+16 -1
View File
@@ -647,7 +647,22 @@
"nextNUnwatched": "Nästa ${count} osedda", "nextNUnwatched": "Nästa ${count} osedda",
"customAmount": "Ange antal...", "customAmount": "Ange antal...",
"howManyEpisodes": "Hur många avsnitt?", "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": { "playlists": {
"title": "Spellistor", "title": "Spellistor",
+16 -1
View File
@@ -647,7 +647,22 @@
"nextNUnwatched": "接下来 ${count} 集未观看", "nextNUnwatched": "接下来 ${count} 集未观看",
"customAmount": "自定义数量...", "customAmount": "自定义数量...",
"howManyEpisodes": "下载几集?", "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": { "playlists": {
"title": "播放列表", "title": "播放列表",
+50 -13
View File
@@ -44,8 +44,10 @@ import 'services/download_storage_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'services/plex_api_cache.dart'; import 'services/plex_api_cache.dart';
import 'database/app_database.dart'; import 'database/app_database.dart';
import 'screens/video_player_screen.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
import 'utils/orientation_helper.dart'; import 'utils/orientation_helper.dart';
import 'utils/watch_state_notifier.dart';
import 'i18n/strings.g.dart'; import 'i18n/strings.g.dart';
import 'focus/input_mode_tracker.dart'; import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart'; import 'focus/key_event_utils.dart';
@@ -362,6 +364,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
late final DownloadManagerService _downloadManager; late final DownloadManagerService _downloadManager;
late final OfflineWatchSyncService _offlineWatchSyncService; late final OfflineWatchSyncService _offlineWatchSyncService;
late final AppLifecycleListener _appLifecycleListener; 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) /// Last time server health probes ran from a resume event (cooldown for desktop)
DateTime _lastResumeProbe = DateTime(0); DateTime _lastResumeProbe = DateTime(0);
@@ -411,6 +416,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_syncDebounce?.cancel();
_watchStateSubscription?.cancel();
_memoryCheckTimer?.cancel(); _memoryCheckTimer?.cancel();
_appLifecycleListener.dispose(); _appLifecycleListener.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
@@ -429,6 +436,34 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
PaintingBinding.instance.imageCache.clearLiveImages(); 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) { switch (state) {
@@ -486,28 +521,30 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
}, },
), ),
// Download provider // Download provider
ChangeNotifierProvider(create: (context) => DownloadProvider(downloadManager: _downloadManager)), ChangeNotifierProvider(
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase)),
// Offline watch sync service // Offline watch sync service
ChangeNotifierProvider<OfflineWatchSyncService>( ChangeNotifierProvider<OfflineWatchSyncService>(
create: (context) { create: (context) {
final offlineModeProvider = context.read<OfflineModeProvider>(); final offlineModeProvider = context.read<OfflineModeProvider>();
final downloadProvider = context.read<DownloadProvider>(); final downloadProvider = context.read<DownloadProvider>();
// Wire up callback to refresh download provider after watch state sync
_offlineWatchSyncService.onWatchStatesRefreshed = () async { _offlineWatchSyncService.onWatchStatesRefreshed = () async {
await downloadProvider.refreshMetadataFromCache(); await _autoDeleteAndSync(downloadProvider);
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);
}
}
}; };
// 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); _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
return _offlineWatchSyncService; return _offlineWatchSyncService;
}, },
+13
View File
@@ -152,6 +152,15 @@ class PlexMetadata with MultiServerFields {
/// Global unique identifier across all servers (serverId:ratingKey) /// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : 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). /// 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`. /// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`.
bool get isLibrarySection => key != null && key!.startsWith('/library/sections/'); bool get isLibrarySection => key != null && key!.startsWith('/library/sections/');
@@ -520,6 +529,10 @@ class PlexMetadata with MultiServerFields {
return viewOffset! > 0 && viewOffset! < duration!; 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 // Helper to determine if content is watched
bool get isWatched { bool get isWatched {
// For series/seasons, check if all episodes are watched // For series/seasons, check if all episodes are watched
+123 -3
View File
@@ -6,11 +6,14 @@ import '../models/download_models.dart';
import '../models/plex_media_version.dart'; import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../utils/download_version_utils.dart'; import '../utils/download_version_utils.dart';
import '../database/app_database.dart';
import '../services/download_manager_service.dart'; import '../services/download_manager_service.dart';
import '../services/download_storage_service.dart'; import '../services/download_storage_service.dart';
import '../services/multi_server_manager.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../services/plex_api_cache.dart'; import '../services/plex_api_cache.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import '../services/sync_rule_executor.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
@@ -36,6 +39,8 @@ class DownloadedArtwork {
/// Provider for managing download state and operations. /// Provider for managing download state and operations.
class DownloadProvider extends ChangeNotifier { class DownloadProvider extends ChangeNotifier {
final DownloadManagerService _downloadManager; final DownloadManagerService _downloadManager;
final AppDatabase _database;
final SyncRuleExecutor _syncRuleExecutor;
StreamSubscription<DownloadProgress>? _progressSubscription; StreamSubscription<DownloadProgress>? _progressSubscription;
StreamSubscription<DeletionProgress>? _deletionProgressSubscription; StreamSubscription<DeletionProgress>? _deletionProgressSubscription;
late final Future<void> _initFuture; late final Future<void> _initFuture;
@@ -59,7 +64,13 @@ class DownloadProvider extends ChangeNotifier {
// Key: globalKey (serverId:ratingKey), Value: total episode count // Key: globalKey (serverId:ratingKey), Value: total episode count
final Map<String, int> _totalEpisodeCounts = {}; 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 // Listen to progress updates from the download manager
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
@@ -127,9 +138,12 @@ class DownloadProvider extends ChangeNotifier {
// Load total episode counts from StorageService // Load total episode counts from StorageService
await _loadTotalEpisodeCounts(); await _loadTotalEpisodeCounts();
// Load sync rules from database
await _loadSyncRules();
appLogger.i( appLogger.i(
'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, ' 'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, '
'and ${_totalEpisodeCounts.length} episode counts', '${_totalEpisodeCounts.length} episode counts, and ${_syncRules.length} sync rules',
); );
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -988,7 +1002,8 @@ class DownloadProvider extends ChangeNotifier {
/// Auto-delete downloaded episodes/movies that are now marked as watched. /// Auto-delete downloaded episodes/movies that are now marked as watched.
/// ///
/// Only deletes individual episodes and movies, never show/season containers. /// 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 deletedTitles = <String>[];
final completedKeys = _downloads.entries final completedKeys = _downloads.entries
@@ -1002,6 +1017,9 @@ class DownloadProvider extends ChangeNotifier {
if (!meta.isEpisode && !meta.isMovie) continue; if (!meta.isEpisode && !meta.isMovie) continue;
if (!meta.isWatched) continue; if (!meta.isWatched) continue;
// Don't delete the episode that's currently playing
if (activeRatingKey != null && meta.ratingKey == activeRatingKey) continue;
try { try {
appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)'); appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)');
await deleteDownload(globalKey); await deleteDownload(globalKey);
@@ -1013,6 +1031,108 @@ class DownloadProvider extends ChangeNotifier {
return deletedTitles; 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 /// Exception thrown when download is blocked due to cellular-only setting
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart';
import '../../models/plex_metadata.dart'; import '../../models/plex_metadata.dart';
import '../../providers/download_provider.dart'; import '../../providers/download_provider.dart';
import '../../providers/multi_server_provider.dart'; import '../../providers/multi_server_provider.dart';
@@ -17,6 +18,7 @@ import '../../widgets/download_tree_view.dart';
import '../main_screen.dart'; import '../main_screen.dart';
import '../libraries/state_messages.dart'; import '../libraries/state_messages.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import 'sync_rules_screen.dart';
class DownloadsScreen extends StatefulWidget { class DownloadsScreen extends StatefulWidget {
const DownloadsScreen({super.key}); const DownloadsScreen({super.key});
@@ -30,6 +32,7 @@ class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderSta
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue'); final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows'); final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies'); final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
final _actionBarKey = GlobalKey<FocusableActionBarState>();
@override @override
List<FocusNode> get tabChipFocusNodes => [_queueTabChipFocusNode, _tvShowsTabChipFocusNode, _moviesTabChipFocusNode]; List<FocusNode> get tabChipFocusNodes => [_queueTabChipFocusNode, _tvShowsTabChipFocusNode, _moviesTabChipFocusNode];
@@ -146,6 +149,20 @@ class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderSta
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
scrolledUnderElevation: 0, 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( SliverFillRemaining(
child: Column( 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,
),
);
}
}
+143 -53
View File
@@ -23,6 +23,8 @@ import '../widgets/plex_optimized_image.dart';
import '../utils/plex_image_helper.dart'; import '../utils/plex_image_helper.dart';
import '../../services/plex_client.dart'; import '../../services/plex_client.dart';
import '../services/plex_api_cache.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_metadata.dart';
import '../models/plex_role.dart'; import '../models/plex_role.dart';
import '../models/plex_video_playback_data.dart'; import '../models/plex_video_playback_data.dart';
@@ -62,6 +64,8 @@ import '../widgets/focusable_tab_chip.dart';
import '../widgets/hub_section.dart'; import '../widgets/hub_section.dart';
import '../models/plex_hub.dart'; import '../models/plex_hub.dart';
enum _SyncRuleAction { edit, remove, delete }
class MediaDetailScreen extends StatefulWidget { class MediaDetailScreen extends StatefulWidget {
final PlexMetadata metadata; final PlexMetadata metadata;
final bool isOffline; final bool isOffline;
@@ -720,7 +724,26 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// State 7: Partial Download (some episodes downloaded, not all) // State 7: Partial Download (some episodes downloaded, not all)
if (progress?.status == DownloadStatus.partial) { if (progress?.status == DownloadStatus.partial) {
final hasSyncRule = downloadProvider.hasSyncRule(globalKey);
final currentFile = progress?.currentFile; 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 final tooltip = currentFile != null
? 'Downloaded $currentFile - Click to complete' ? 'Downloaded $currentFile - Click to complete'
: 'Partially downloaded - Click to complete'; : 'Partially downloaded - Click to complete';
@@ -755,6 +778,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// State 8: Downloaded/Completed (can delete) // State 8: Downloaded/Completed (can delete)
if (downloadProvider.isDownloaded(globalKey)) { 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( return IconButton.filledTonal(
onPressed: () async { onPressed: () async {
// Show delete download confirmation // Show delete download confirmation
@@ -785,18 +823,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (client == null) return; if (client == null) return;
try { try {
final count = await showDownloadOptionsAndQueue( final result = await showDownloadOptionsAndQueue(
context, context,
metadata: metadata, metadata: metadata,
client: client, client: client,
downloadProvider: downloadProvider, downloadProvider: downloadProvider,
); );
if (count == null || !context.mounted) return; if (result == null || !context.mounted) return;
final message = count > 1 showSuccessSnackBar(context, result.toSnackBarMessage());
? t.downloads.episodesQueued(count: count)
: t.downloads.downloadQueued;
showSuccessSnackBar(context, message);
} on CellularDownloadBlockedException { } on CellularDownloadBlockedException {
if (context.mounted) { if (context.mounted) {
showErrorSnackBar(context, t.settings.cellularDownloadBlocked); showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
@@ -829,7 +864,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
context, context,
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
); );
// Refresh offline OnDeck _updateWatchStateOffline();
_loadOfflineOnDeckEpisode(); _loadOfflineOnDeckEpisode();
} }
} else { } else {
@@ -838,15 +873,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (client == null) return; if (client == null) return;
if (isWatched) { if (isWatched) {
await client.markAsUnwatched(metadata.ratingKey); await client.markAsUnwatched(metadata.ratingKey, metadata: metadata);
} else { } else {
await client.markAsWatched(metadata.ratingKey); await client.markAsWatched(metadata.ratingKey, metadata: metadata);
} }
if (mounted) { if (mounted) {
_watchStateChanged = true; _watchStateChanged = true;
showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched);
// Update watch state without full rebuild
_updateWatchState();
} }
} }
} catch (e) { } catch (e) {
@@ -1039,13 +1072,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (client == null) return; if (client == null) return;
final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale
final success = await client.rateItem(metadata.ratingKey, plexRating); final success = await client.rateItem(metadata.ratingKey, plexRating);
if (success) _updateWatchState(); if (success) {
setStateIfMounted(() {
_fullMetadata = _fullMetadata?.copyWith(userRating: plexRating);
});
}
}, },
onClear: () async { onClear: () async {
final client = _getClientForMetadata(this.context); final client = _getClientForMetadata(this.context);
if (client == null) return; if (client == null) return;
final success = await client.rateItem(metadata.ratingKey, -1); 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 { Future<void> _loadFullMetadata() async {
setState(() { setState(() {
_isLoadingMetadata = true; _isLoadingMetadata = true;
@@ -1691,7 +1793,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
item: season, item: season,
onRefresh: (_) { onRefresh: (_) {
_watchStateChanged = true; _watchStateChanged = true;
_updateWatchState();
}, },
onListRefresh: () { onListRefresh: () {
if (widget.isOffline) { 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 { /// Offline: update viewCount in the API cache and re-read metadata from it.
// Use server-specific client for this metadata Future<void> _updateWatchStateOffline() async {
final client = _getClientForMetadata(context); final serverId = widget.metadata.serverId;
if (client == null) return; 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) { final endpoint = '/library/metadata/$ratingKey';
// Preserve serverId from original metadata final cached = await cache.get(serverId, endpoint);
final metadataWithServerId = metadata.copyWith( final json = PlexCacheParser.extractFirstMetadata(cached);
serverId: widget.metadata.serverId, if (json == null) return;
serverName: widget.metadata.serverName,
);
// For shows, also refetch seasons to update their watch counts final localStatus = await syncService.getLocalWatchStatus('$serverId:$ratingKey');
List<PlexMetadata>? updatedSeasons; if (localStatus == true) {
if (metadata.isShow) { json['viewCount'] = 1;
final seasons = await client.getChildren(widget.metadata.ratingKey); } else if (localStatus == false) {
// Preserve serverId for each season json['viewCount'] = 0;
updatedSeasons = seasons json['viewOffset'] = 0;
.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
} }
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 { Future<void> _playFirstEpisode() async {
+25 -12
View File
@@ -46,6 +46,9 @@ class PlaybackProgressTracker {
/// Counts timer ticks while paused to send periodic "paused" heartbeats. /// Counts timer ticks while paused to send periodic "paused" heartbeats.
int _pausedTickCounter = 0; int _pausedTickCounter = 0;
/// Whether we've already scrobbled (marked as watched) for this playback session.
bool _scrobbled = false;
PlaybackProgressTracker({ PlaybackProgressTracker({
required this.client, required this.client,
required this.metadata, required this.metadata,
@@ -56,10 +59,6 @@ class PlaybackProgressTracker {
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'), }) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
assert(isOffline || client != null, 'client is required when isOffline is false'); 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() { void startTracking() {
if (_progressTimer != null) { if (_progressTimer != null) {
appLogger.w('Progress tracking already started'); appLogger.w('Progress tracking already started');
@@ -99,17 +98,12 @@ class PlaybackProgressTracker {
appLogger.d('Started progress tracking (interval: ${updateInterval.inSeconds}s, offline: $isOffline)'); appLogger.d('Started progress tracking (interval: ${updateInterval.inSeconds}s, offline: $isOffline)');
} }
/// Stop tracking playback progress
///
/// Cancels the periodic timer.
void stopTracking() { void stopTracking() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
appLogger.d('Stopped progress tracking'); appLogger.d('Stopped progress tracking');
} }
/// Send progress update to Plex server or queue locally
///
/// [state] can be 'playing', 'paused', or 'stopped' /// [state] can be 'playing', 'paused', or 'stopped'
Future<void> sendProgress(String state) async { Future<void> sendProgress(String state) async {
await _sendProgress(state); await _sendProgress(state);
@@ -150,12 +144,14 @@ class PlaybackProgressTracker {
}); });
} }
// Emit watch state event on stop for UI updates across screens // Emit watch state event on stop for UI updates across screens.
if (state == 'stopped' && position.inMilliseconds > 0) { // Skip if already scrobbled — markAsWatched already emitted a watched event.
if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled) {
WatchStateNotifier().notifyProgress( WatchStateNotifier().notifyProgress(
metadata: metadata, metadata: metadata,
viewOffset: position.inMilliseconds, viewOffset: position.inMilliseconds,
duration: duration.inMilliseconds, duration: duration.inMilliseconds,
watchedThreshold: client != null ? client!.watchedThresholdPercent / 100.0 : 0.9,
); );
} }
} catch (e) { } catch (e) {
@@ -188,6 +184,24 @@ class PlaybackProgressTracker {
state: state, state: state,
duration: duration.inMilliseconds, 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) /// Queue progress update locally (offline mode)
@@ -211,7 +225,6 @@ class PlaybackProgressTracker {
); );
} }
/// Dispose resources
void dispose() { void dispose() {
stopTracking(); stopTracking();
} }
+184
View File
@@ -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);
}
}
}
}
+1 -13
View File
@@ -79,23 +79,11 @@ class DeletionNotifier extends BaseNotifier<DeletionEvent> {
DeletionEvent( DeletionEvent(
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
serverId: metadata.serverId ?? '', serverId: metadata.serverId ?? '',
parentChain: _buildParentChain(metadata), parentChain: metadata.parentChain,
mediaType: metadata.type ?? '', mediaType: metadata.type ?? '',
leafCount: metadata.leafCount ?? 1, leafCount: metadata.leafCount ?? 1,
isDownloadOnly: isDownloadOnly, 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;
}
} }
+100 -6
View File
@@ -5,16 +5,36 @@ import '../i18n/strings.g.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import 'content_utils.dart';
import 'dialogs.dart'; import 'dialogs.dart';
import 'download_version_utils.dart'; import 'download_version_utils.dart';
import 'global_key_utils.dart';
/// Dialog option for the download picker. Typed to avoid stringly-typed values. /// Dialog option for the download picker. Typed to avoid stringly-typed values.
enum _DownloadChoice { all, unwatched, next5, next10, custom } 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. /// Shows download options dialog for shows/seasons, then queues the download.
/// For movies/episodes, queues directly without a dialog. /// For movies/episodes, queues directly without a dialog.
/// Returns the number of items queued, or null if cancelled. /// Returns a [DownloadResult], or null if cancelled.
Future<int?> showDownloadOptionsAndQueue( Future<DownloadResult?> showDownloadOptionsAndQueue(
BuildContext context, { BuildContext context, {
required PlexMetadata metadata, required PlexMetadata metadata,
required PlexClient client, required PlexClient client,
@@ -24,6 +44,7 @@ Future<int?> showDownloadOptionsAndQueue(
var filter = DownloadFilter.all; var filter = DownloadFilter.all;
int? maxCount; int? maxCount;
bool keepSynced = false;
if (mt == PlexMediaType.show || mt == PlexMediaType.season) { if (mt == PlexMediaType.show || mt == PlexMediaType.season) {
int? customCount; int? customCount;
@@ -61,6 +82,20 @@ Future<int?> showDownloadOptionsAndQueue(
filter = DownloadFilter.unwatched; filter = DownloadFilter.unwatched;
maxCount = customCount; 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; if (!context.mounted) return null;
@@ -68,13 +103,35 @@ Future<int?> showDownloadOptionsAndQueue(
final versionConfig = await resolveDownloadVersion(context, metadata, client); final versionConfig = await resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !context.mounted) return null; 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, metadata,
client, client,
versionConfig: versionConfig, versionConfig: versionConfig,
filter: filter, filter: filter,
maxCount: maxCount, maxCount: maxCount,
); );
return DownloadResult(
count: count,
syncRuleCreated: keepSynced && !syncRuleUpdated,
syncRuleUpdated: syncRuleUpdated,
);
} }
/// Shows download options dialog for playlists, then queues the download. /// 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( final result = await showTextInputDialog(
context, context,
title: t.downloads.howManyEpisodes, title: title ?? t.downloads.howManyEpisodes,
labelText: '', labelText: '',
hintText: '', hintText: hintText ?? '',
confirmText: t.common.ok, confirmText: t.common.ok,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
@@ -121,3 +178,40 @@ Future<int?> _showEpisodeCountDialog(BuildContext context) async {
if (result == null) return null; if (result == null) return null;
return int.tryParse(result); 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;
}
+12 -18
View File
@@ -85,40 +85,34 @@ class WatchStateNotifier extends BaseNotifier<WatchStateEvent> {
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
serverId: metadata.serverId ?? '', serverId: metadata.serverId ?? '',
changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched, changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched,
parentChain: _buildParentChain(metadata), parentChain: metadata.parentChain,
mediaType: metadata.type ?? '', mediaType: metadata.type ?? '',
isNowWatched: isNowWatched, isNowWatched: isNowWatched,
), ),
); );
} }
/// Helper to emit a progress update event /// Helper to emit a progress update event.
void notifyProgress({required PlexMetadata metadata, required int viewOffset, required int duration}) { /// [watchedThreshold] defaults to 0.9 — pass the server's configured value
const threshold = 0.9; /// (`client.watchedThresholdPercent / 100.0`) when available.
final isNowWatched = duration > 0 && (viewOffset / duration) >= threshold; void notifyProgress({
required PlexMetadata metadata,
required int viewOffset,
required int duration,
double watchedThreshold = 0.9,
}) {
final isNowWatched = duration > 0 && (viewOffset / duration) >= watchedThreshold;
notify( notify(
WatchStateEvent( WatchStateEvent(
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
serverId: metadata.serverId ?? '', serverId: metadata.serverId ?? '',
changeType: WatchStateChangeType.progressUpdate, changeType: WatchStateChangeType.progressUpdate,
parentChain: _buildParentChain(metadata), parentChain: metadata.parentChain,
mediaType: metadata.type ?? '', mediaType: metadata.type ?? '',
viewOffset: viewOffset, viewOffset: viewOffset,
isNowWatched: isNowWatched, 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;
}
} }
+1 -6
View File
@@ -858,12 +858,7 @@ class _MediaCardHelpers {
), ),
), ),
// Progress bar for seasons (viewedLeafCount / leafCount) // Progress bar for seasons (viewedLeafCount / leafCount)
if (metadata.isSeason && if (metadata.isSeason && metadata.isPartiallyWatched)
metadata.viewedLeafCount != null &&
metadata.leafCount != null &&
metadata.leafCount! > 0 &&
metadata.viewedLeafCount! > 0 &&
metadata.viewedLeafCount! < metadata.leafCount!)
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
+60 -11
View File
@@ -126,12 +126,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final mediaType = isPlaylist ? null : metadata!.mediaType; final mediaType = isPlaylist ? null : metadata!.mediaType;
final isCollection = mediaType == PlexMediaType.collection; final isCollection = mediaType == PlexMediaType.collection;
final isPartiallyWatched = final isPartiallyWatched = !isPlaylist && metadata!.isPartiallyWatched;
!isPlaylist &&
metadata!.viewedLeafCount != null &&
metadata.leafCount != null &&
metadata.viewedLeafCount! > 0 &&
metadata.viewedLeafCount! < metadata.leafCount!;
final hasActiveProgress = final hasActiveProgress =
mediaType != null && mediaType != null &&
@@ -302,8 +297,23 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false); final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
final globalKey = metadata.globalKey; final globalKey = metadata.globalKey;
final isDownloaded = downloadProvider.isDownloaded(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 // Show delete download option
menuActions.add( menuActions.add(
_MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload),
@@ -551,6 +561,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
await _handleDeleteDownload(context); await _handleDeleteDownload(context);
break; break;
case 'manage_sync':
await _handleManageSyncRule(context);
break;
case 'remove_sync':
await _handleRemoveSyncRule(context);
break;
case 'delete_media': case 'delete_media':
await _handleDeleteMediaItem(context, mediaType); await _handleDeleteMediaItem(context, mediaType);
break; break;
@@ -1157,16 +1175,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final client = _getClientForItem(); final client = _getClientForItem();
try { try {
final count = await showDownloadOptionsAndQueue( final result = await showDownloadOptionsAndQueue(
context, context,
metadata: metadata, metadata: metadata,
client: client, client: client,
downloadProvider: downloadProvider, 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, result.toSnackBarMessage());
showSuccessSnackBar(context, message);
} on CellularDownloadBlockedException { } on CellularDownloadBlockedException {
if (context.mounted) { if (context.mounted) {
showErrorSnackBar(context, t.settings.cellularDownloadBlocked); 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 /// Handle delete media item action
/// This permanently removes the media item and its associated files from the server /// This permanently removes the media item and its associated files from the server
Future<void> _handleDeleteMediaItem(BuildContext context, PlexMediaType? mediaType) async { Future<void> _handleDeleteMediaItem(BuildContext context, PlexMediaType? mediaType) async {