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