feature: Re-implemented syncing

This commit is contained in:
PartyDonut 2025-07-27 10:54:29 +02:00
parent c5c7f71b84
commit 86ff355e21
51 changed files with 3067 additions and 1147 deletions

View file

@ -0,0 +1,193 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/images_models.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/sync_item.dart';
import 'package:fladder/providers/user_provider.dart';
part 'database_item.g.dart';
@TableIndex(name: 'database_id', columns: {#id})
class DatabaseItems extends Table {
TextColumn get userId => text()();
TextColumn get id => text().withLength(min: 1)();
BoolColumn get syncing => boolean()();
TextColumn get sortName => text().nullable()();
TextColumn get parentId => text().nullable()();
TextColumn get path => text().nullable()();
IntColumn get fileSize => integer().nullable()();
TextColumn get videoFileName => text().nullable()();
TextColumn get trickPlayModel => text().nullable()();
TextColumn get mediaSegments => text().nullable()();
TextColumn get images => text().nullable()();
TextColumn get chapters => text().nullable()();
TextColumn get subtitles => text().nullable()();
TextColumn get userData => text().nullable()();
@override
Set<Column<Object>> get primaryKey => {id};
}
@DriftDatabase(tables: [DatabaseItems])
class AppDatabase extends _$AppDatabase {
AppDatabase(this.ref, [QueryExecutor? executor]) : super(executor ?? _openConnection());
final Ref ref;
String get userId => ref.read(userProvider.select((value) => value?.id ?? ""));
@override
int get schemaVersion => 1;
Future<void> clearDatabase() {
return transaction(() async {
for (final table in allTables) {
await delete(table).go();
}
});
}
Selectable<SyncedItem> getItem(String id) =>
(select(databaseItems)..where((tbl) => tbl.id.equals(id) & tbl.userId.equals(userId))).map(databaseConverter);
Selectable<SyncedItem> getParent(String id) =>
(select(databaseItems)..where((tbl) => tbl.parentId.equals(id) & tbl.userId.equals(userId)))
.map(databaseConverter);
Selectable<SyncedItem> get getParentItems =>
((select(databaseItems)..where((tbl) => (tbl.parentId.isNull() & tbl.userId.equals(userId))))
..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
.map(databaseConverter);
Selectable<SyncedItem> getChildren(String parentId) =>
((select(databaseItems)..where((tbl) => (tbl.parentId.equals(parentId) & tbl.userId.equals(userId))))
..orderBy([(t) => OrderingTerm(expression: t.sortName)]))
.map(databaseConverter);
Future<int> insertItem(SyncedItem item) async {
final itemExists = await getItem(item.id).getSingleOrNull();
if (itemExists != null) {
return (update(databaseItems)..where((tbl) => tbl.id.equals(item.id) & tbl.userId.equals(userId)))
.write(toDataBaseItem(item));
} else {
return into(databaseItems).insert(toDataBaseItem(item));
}
}
Future<List<SyncedItem>> getNestedChildren(SyncedItem root) async {
final itemType = root.createItemModel(ref)?.type;
if (itemType == null) return [];
final int maxDepth = switch (itemType) {
FladderItemType.episode => 0,
FladderItemType.movie => 0,
FladderItemType.season => 1,
FladderItemType.series => 2,
_ => 1,
};
final all = <SyncedItem>[];
List<SyncedItem> toProcess = [root];
if (maxDepth == 0) {
return [];
}
for (var i = 0; i < maxDepth; i++) {
final futures = toProcess.map((item) => getChildren(item.id).get());
final resultsList = await Future.wait(futures);
final children = resultsList.expand((r) => r).toList();
if (children.isEmpty) break;
all.addAll(children);
toProcess = children;
}
return all;
}
Future<void> insertMultipleEntries(List<SyncedItem> items) async {
await batch((batch) {
batch.insertAll(
databaseItems,
items.map(toDataBaseItem),
mode: InsertMode.insertOrReplace,
);
});
}
Future<void> deleteAllItems(List<SyncedItem> items) async => await batch((batch) {
batch.deleteWhere(databaseItems, (tbl) => tbl.id.isIn(items.map((e) => e.id)));
});
DatabaseItemsCompanion toDataBaseItem(SyncedItem item) {
return DatabaseItemsCompanion(
id: Value(item.id),
parentId: Value(item.parentId),
syncing: Value(item.syncing),
userId: Value(userId),
path: Value(item.path),
fileSize: Value(item.fileSize),
sortName: Value(item.sortName),
videoFileName: Value(item.videoFileName),
trickPlayModel: Value(item.fTrickPlayModel != null ? jsonEncode(item.fTrickPlayModel?.toJson()) : null),
mediaSegments: Value(item.mediaSegments != null ? jsonEncode(item.mediaSegments?.toJson()) : null),
images: Value(item.fImages != null ? jsonEncode(item.fImages?.toJson()) : null),
chapters: Value(jsonEncode(item.fChapters.map((e) => e.toJson()).toList())),
subtitles: Value(jsonEncode(item.subtitles.map((e) => e.toJson()).toList())),
userData: Value(item.userData != null ? jsonEncode(item.userData?.toJson()) : null),
);
}
SyncedItem databaseConverter(DatabaseItem dataItem) {
final syncedItem = SyncedItem(
id: dataItem.id,
userId: dataItem.userId,
parentId: dataItem.parentId,
sortName: dataItem.sortName,
syncing: dataItem.syncing,
path: dataItem.path,
fileSize: dataItem.fileSize,
videoFileName: dataItem.videoFileName,
fTrickPlayModel:
dataItem.trickPlayModel != null ? TrickPlayModel.fromJson(jsonDecode(dataItem.trickPlayModel!)) : null,
mediaSegments:
dataItem.mediaSegments != null ? MediaSegmentsModel.fromJson(jsonDecode(dataItem.mediaSegments!)) : null,
fImages: dataItem.images != null ? ImagesData.fromJson(jsonDecode(dataItem.images!)) : null,
fChapters: (dataItem.chapters != null && dataItem.chapters!.isNotEmpty)
? (jsonDecode(dataItem.chapters!) as List).map((e) => Chapter.fromJson(e)).toList()
: [],
subtitles: (dataItem.subtitles != null && dataItem.subtitles!.isNotEmpty)
? (jsonDecode(dataItem.subtitles!) as List).map((e) => SubStreamModel.fromJson(e)).toList()
: [],
userData: dataItem.userData != null ? UserData.fromJson(jsonDecode(dataItem.userData!)) : null,
);
return syncedItem.copyWith(
itemModel: syncedItem.createItemModel(ref),
);
}
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'syncedDatabase',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
),
// If you need web support, see https://drift.simonbinder.eu/platforms/web/
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -6,24 +6,6 @@ import 'package:fladder/models/syncing/sync_item.dart';
part 'i_synced_item.g.dart';
// extension IsarExtensions on String? {
// int get fastHash {
// if (this == null) return 0;
// var hash = 0xcbf29ce484222325;
// var i = 0;
// while (i < this!.length) {
// final codeUnit = this!.codeUnitAt(i++);
// hash ^= codeUnit >> 8;
// hash *= 0x100000001b3;
// hash ^= codeUnit & 0xFF;
// hash *= 0x100000001b3;
// }
// return hash;
// }
// }
@collection
class ISyncedItem {
String? userId;

View file

@ -18,7 +18,6 @@ import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart';
import 'package:fladder/models/syncing/i_synced_item.dart';
import 'package:fladder/providers/sync/sync_provider_helpers.dart';
import 'package:fladder/providers/sync_provider.dart';
import 'package:fladder/util/localization_helper.dart';
@ -44,6 +43,8 @@ class SyncedItem with _$SyncedItem {
@Default([]) List<Chapter> fChapters,
@Default([]) List<SubStreamModel> subtitles,
@UserDataJsonSerializer() UserData? userData,
// ignore: invalid_annotation_target
@JsonKey(includeFromJson: false, includeToJson: false) ItemBaseModel? itemModel,
}) = _SyncItem;
static String trickPlayPath = "TrickPlay";
@ -70,9 +71,9 @@ class SyncedItem with _$SyncedItem {
File get videoFile => File(joinAll(["$path", "$videoFileName"]));
Directory get directory => Directory(path ?? "");
SyncStatus get status => switch (videoFile.existsSync()) {
true => SyncStatus.complete,
_ => SyncStatus.partially,
TaskStatus get status => switch (videoFile.existsSync()) {
true => TaskStatus.complete,
_ => TaskStatus.notFound,
};
String? get taskId => task?.taskId;
@ -103,10 +104,9 @@ class SyncedItem with _$SyncedItem {
return true;
}
List<SyncedItem> nestedChildren(WidgetRef ref) => ref.watch(syncChildrenProvider(this));
List<SyncedItem> getChildren(Ref ref) => ref.read(syncProvider.notifier).getChildren(this);
List<SyncedItem> getNestedChildren(Ref ref) => ref.read(syncProvider.notifier).getNestedChildren(this);
Future<List<SyncedItem>> getChildren(Ref ref) async => await ref.read(syncProvider.notifier).getChildren(this);
Future<List<SyncedItem>> getNestedChildren(Ref ref) async =>
await ref.read(syncProvider.notifier).getNestedChildren(this);
Future<int> get getDirSize async {
var files = await directory.list(recursive: true).toList();
@ -158,44 +158,45 @@ class SyncedItem with _$SyncedItem {
}
}
enum SyncStatus {
complete(
Color.fromARGB(255, 141, 214, 58),
IconsaxPlusLinear.tick_circle,
),
partially(
Color.fromARGB(255, 221, 135, 23),
IconsaxPlusLinear.more_circle,
),
;
const SyncStatus(this.color, this.icon);
final Color color;
String label(BuildContext context) {
return switch (this) {
SyncStatus.partially => context.localized.syncStatusPartially,
SyncStatus.complete => context.localized.syncStatusSynced,
};
}
final IconData icon;
}
extension StatusExtension on TaskStatus {
Color color(BuildContext context) => switch (this) {
TaskStatus.enqueued => Colors.blueAccent,
TaskStatus.running => Colors.limeAccent,
TaskStatus.complete => Colors.limeAccent,
TaskStatus.canceled || TaskStatus.notFound || TaskStatus.failed => Theme.of(context).colorScheme.error,
TaskStatus.waitingToRetry => Colors.yellowAccent,
TaskStatus.paused => Colors.orangeAccent,
IconData get icon => switch (this) {
TaskStatus.enqueued => IconsaxPlusLinear.calendar_circle,
TaskStatus.running => IconsaxPlusLinear.arrow_down_1,
TaskStatus.complete => IconsaxPlusLinear.tick_circle,
TaskStatus.notFound => IconsaxPlusLinear.warning_2,
TaskStatus.failed => IconsaxPlusLinear.tag_cross,
TaskStatus.canceled => IconsaxPlusLinear.tag_cross,
TaskStatus.waitingToRetry => IconsaxPlusLinear.clock,
TaskStatus.paused => IconsaxPlusLinear.pause_circle,
};
Color color(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return isDarkMode
? switch (this) {
TaskStatus.enqueued => Colors.blueAccent,
TaskStatus.running => Colors.greenAccent,
TaskStatus.complete => Colors.limeAccent,
TaskStatus.notFound => const Color.fromARGB(255, 221, 135, 23),
TaskStatus.canceled || TaskStatus.failed => Theme.of(context).colorScheme.error,
TaskStatus.waitingToRetry => Colors.yellowAccent,
TaskStatus.paused => Colors.tealAccent,
}
: switch (this) {
TaskStatus.enqueued => Colors.blue,
TaskStatus.running => Colors.green,
TaskStatus.complete => Colors.lime,
TaskStatus.notFound => const Color.fromARGB(255, 221, 135, 23),
TaskStatus.canceled || TaskStatus.failed => Theme.of(context).colorScheme.error,
TaskStatus.waitingToRetry => Colors.yellow,
TaskStatus.paused => Colors.teal,
};
}
String name(BuildContext context) => switch (this) {
TaskStatus.enqueued => context.localized.syncStatusEnqueued,
TaskStatus.running => context.localized.syncStatusRunning,
TaskStatus.complete => context.localized.syncStatusComplete,
TaskStatus.complete => context.localized.syncStatusSynced,
TaskStatus.notFound => context.localized.syncStatusNotFound,
TaskStatus.failed => context.localized.syncStatusFailed,
TaskStatus.canceled => context.localized.syncStatusCanceled,

View file

@ -31,7 +31,10 @@ mixin _$SyncedItem {
List<Chapter> get fChapters => throw _privateConstructorUsedError;
List<SubStreamModel> get subtitles => throw _privateConstructorUsedError;
@UserDataJsonSerializer()
UserData? get userData => throw _privateConstructorUsedError;
UserData? get userData =>
throw _privateConstructorUsedError; // ignore: invalid_annotation_target
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? get itemModel => throw _privateConstructorUsedError;
/// Create a copy of SyncedItem
/// with the given fields replaced by the non-null parameter values.
@ -61,7 +64,9 @@ abstract class $SyncedItemCopyWith<$Res> {
ImagesData? fImages,
List<Chapter> fChapters,
List<SubStreamModel> subtitles,
@UserDataJsonSerializer() UserData? userData});
@UserDataJsonSerializer() UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? itemModel});
$TrickPlayModelCopyWith<$Res>? get fTrickPlayModel;
}
@ -96,6 +101,7 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem>
Object? fChapters = null,
Object? subtitles = null,
Object? userData = freezed,
Object? itemModel = freezed,
}) {
return _then(_value.copyWith(
id: null == id
@ -158,6 +164,10 @@ class _$SyncedItemCopyWithImpl<$Res, $Val extends SyncedItem>
? _value.userData
: userData // ignore: cast_nullable_to_non_nullable
as UserData?,
itemModel: freezed == itemModel
? _value.itemModel
: itemModel // ignore: cast_nullable_to_non_nullable
as ItemBaseModel?,
) as $Val);
}
@ -199,7 +209,9 @@ abstract class _$$SyncItemImplCopyWith<$Res>
ImagesData? fImages,
List<Chapter> fChapters,
List<SubStreamModel> subtitles,
@UserDataJsonSerializer() UserData? userData});
@UserDataJsonSerializer() UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? itemModel});
@override
$TrickPlayModelCopyWith<$Res>? get fTrickPlayModel;
@ -233,6 +245,7 @@ class __$$SyncItemImplCopyWithImpl<$Res>
Object? fChapters = null,
Object? subtitles = null,
Object? userData = freezed,
Object? itemModel = freezed,
}) {
return _then(_$SyncItemImpl(
id: null == id
@ -295,6 +308,10 @@ class __$$SyncItemImplCopyWithImpl<$Res>
? _value.userData
: userData // ignore: cast_nullable_to_non_nullable
as UserData?,
itemModel: freezed == itemModel
? _value.itemModel
: itemModel // ignore: cast_nullable_to_non_nullable
as ItemBaseModel?,
));
}
}
@ -317,7 +334,8 @@ class _$SyncItemImpl extends _SyncItem {
this.fImages,
final List<Chapter> fChapters = const [],
final List<SubStreamModel> subtitles = const [],
@UserDataJsonSerializer() this.userData})
@UserDataJsonSerializer() this.userData,
@JsonKey(includeFromJson: false, includeToJson: false) this.itemModel})
: _fChapters = fChapters,
_subtitles = subtitles,
super._();
@ -369,10 +387,14 @@ class _$SyncItemImpl extends _SyncItem {
@override
@UserDataJsonSerializer()
final UserData? userData;
// ignore: invalid_annotation_target
@override
@JsonKey(includeFromJson: false, includeToJson: false)
final ItemBaseModel? itemModel;
@override
String toString() {
return 'SyncedItem(id: $id, syncing: $syncing, parentId: $parentId, userId: $userId, path: $path, markedForDelete: $markedForDelete, sortName: $sortName, fileSize: $fileSize, videoFileName: $videoFileName, mediaSegments: $mediaSegments, fTrickPlayModel: $fTrickPlayModel, fImages: $fImages, fChapters: $fChapters, subtitles: $subtitles, userData: $userData)';
return 'SyncedItem(id: $id, syncing: $syncing, parentId: $parentId, userId: $userId, path: $path, markedForDelete: $markedForDelete, sortName: $sortName, fileSize: $fileSize, videoFileName: $videoFileName, mediaSegments: $mediaSegments, fTrickPlayModel: $fTrickPlayModel, fImages: $fImages, fChapters: $fChapters, subtitles: $subtitles, userData: $userData, itemModel: $itemModel)';
}
/// Create a copy of SyncedItem
@ -400,7 +422,9 @@ abstract class _SyncItem extends SyncedItem {
final ImagesData? fImages,
final List<Chapter> fChapters,
final List<SubStreamModel> subtitles,
@UserDataJsonSerializer() final UserData? userData}) = _$SyncItemImpl;
@UserDataJsonSerializer() final UserData? userData,
@JsonKey(includeFromJson: false, includeToJson: false)
final ItemBaseModel? itemModel}) = _$SyncItemImpl;
_SyncItem._() : super._();
@override
@ -433,7 +457,10 @@ abstract class _SyncItem extends SyncedItem {
List<SubStreamModel> get subtitles;
@override
@UserDataJsonSerializer()
UserData? get userData;
UserData? get userData; // ignore: invalid_annotation_target
@override
@JsonKey(includeFromJson: false, includeToJson: false)
ItemBaseModel? get itemModel;
/// Create a copy of SyncedItem
/// with the given fields replaced by the non-null parameter values.