feat(mobile): stack sync (#19735)

* feat(mobile): stack sync

* fix: lint

* Update mobile/lib/infrastructure/repositories/sync_api.repository.dart

Co-authored-by: Alex <alex.tran1502@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Daimolean 2025-07-07 11:01:09 +08:00 committed by GitHub
parent 4ce9bce414
commit cc471806fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 267 additions and 32 deletions

Binary file not shown.

View file

@ -0,0 +1,84 @@
import 'dart:convert';
// Model for a stack stored in the server
class Stack {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String primaryAssetId;
const Stack({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.primaryAssetId,
});
Stack copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? primaryAssetId,
}) {
return Stack(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
primaryAssetId: primaryAssetId ?? this.primaryAssetId,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'createdAt': createdAt.millisecondsSinceEpoch,
'updatedAt': updatedAt.millisecondsSinceEpoch,
'ownerId': ownerId,
'primaryAssetId': primaryAssetId,
};
}
factory Stack.fromMap(Map<String, dynamic> map) {
return Stack(
id: map['id'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int),
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int),
ownerId: map['ownerId'] as String,
primaryAssetId: map['primaryAssetId'] as String,
);
}
String toJson() => json.encode(toMap());
factory Stack.fromJson(String source) =>
Stack.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'Stack(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, ownerId: $ownerId, primaryAssetId: $primaryAssetId)';
}
@override
bool operator ==(covariant Stack other) {
if (identical(this, other)) return true;
return other.id == id &&
other.createdAt == createdAt &&
other.updatedAt == updatedAt &&
other.ownerId == ownerId &&
other.primaryAssetId == primaryAssetId;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
ownerId.hashCode ^
primaryAssetId.hashCode;
}
}

View file

@ -154,6 +154,25 @@ class SyncStreamService {
return _syncStreamRepository.updateMemoryAssetsV1(data.cast());
case SyncEntityType.memoryToAssetDeleteV1:
return _syncStreamRepository.deleteMemoryAssetsV1(data.cast());
case SyncEntityType.stackV1:
return _syncStreamRepository.updateStacksV1(data.cast());
case SyncEntityType.stackDeleteV1:
return _syncStreamRepository.deleteStacksV1(data.cast());
case SyncEntityType.partnerStackV1:
return _syncStreamRepository.updateStacksV1(
data.cast(),
debugLabel: 'partner',
);
case SyncEntityType.partnerStackBackfillV1:
return _syncStreamRepository.updateStacksV1(
data.cast(),
debugLabel: 'partner backfill',
);
case SyncEntityType.partnerStackDeleteV1:
return _syncStreamRepository.deleteStacksV1(
data.cast(),
debugLabel: 'partner',
);
default:
_logger.warning("Unknown sync data type: $type");
}

View file

@ -0,0 +1,22 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class StackEntity extends Table with DriftDefaultsMixin {
const StackEntity();
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get primaryAssetId => text().references(RemoteAssetEntity, #id)();
@override
Set<Column> get primaryKey => {id};
}

Binary file not shown.

View file

@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:isar/isar.dart';
@ -50,6 +51,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAlbumUserEntity,
MemoryEntity,
MemoryAssetEntity,
StackEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',

View file

@ -0,0 +1,30 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftStackRepository(this._db) : super(_db);
Future<List<Stack>> getAll(String userId) {
final query = _db.stackEntity.select()
..where((e) => e.ownerId.equals(userId));
return query.map((stack) {
return stack.toDto();
}).get();
}
}
extension on StackEntityData {
Stack toDto() {
return Stack(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
primaryAssetId: primaryAssetId,
);
}
}

View file

@ -54,6 +54,8 @@ class SyncApiRepository {
SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1,
SyncRequestType.memoryToAssetsV1,
SyncRequestType.stacksV1,
SyncRequestType.partnerStacksV1,
],
).toJson(),
);
@ -163,6 +165,11 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.memoryDeleteV1: SyncMemoryDeleteV1.fromJson,
SyncEntityType.memoryToAssetV1: SyncMemoryAssetV1.fromJson,
SyncEntityType.memoryToAssetDeleteV1: SyncMemoryAssetDeleteV1.fromJson,
SyncEntityType.stackV1: SyncStackV1.fromJson,
SyncEntityType.stackDeleteV1: SyncStackDeleteV1.fromJson,
SyncEntityType.partnerStackV1: SyncStackV1.fromJson,
SyncEntityType.partnerStackBackfillV1: SyncStackV1.fromJson,
SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson,
};
class _SyncAckV1 {

View file

@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:logging/logging.dart';
@ -69,8 +70,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: SyncPartnerDeleteV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: SyncPartnerDeleteV1', error, stack);
rethrow;
}
}
@ -92,8 +93,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: SyncPartnerV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: SyncPartnerV1', error, stack);
rethrow;
}
}
@ -104,10 +105,10 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}) async {
try {
await _db.remoteAssetEntity.deleteWhere(
(row) => row.id.isIn(data.map((error) => error.assetId)),
(row) => row.id.isIn(data.map((e) => e.assetId)),
);
} catch (error, stackTrace) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow;
}
}
@ -142,8 +143,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: updateAssetsV1 - $debugLabel', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: updateAssetsV1 - $debugLabel', error, stack);
rethrow;
}
}
@ -186,11 +187,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
} catch (error, stack) {
_logger.severe(
'Error: updateAssetsExifV1 - $debugLabel',
error,
stackTrace,
stack,
);
rethrow;
}
@ -201,8 +202,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.remoteAlbumEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.albumId)),
);
} catch (error, stackTrace) {
_logger.severe('Error: deleteAlbumsV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow;
}
}
@ -229,8 +230,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: updateAlbumsV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: updateAlbumsV1', error, stack);
rethrow;
}
}
@ -248,8 +249,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: deleteAlbumUsersV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: deleteAlbumUsersV1', error, stack);
rethrow;
}
}
@ -275,11 +276,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
} catch (error, stack) {
_logger.severe(
'Error: updateAlbumUsersV1 - $debugLabel',
error,
stackTrace,
stack,
);
rethrow;
}
@ -300,8 +301,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: deleteAlbumToAssetsV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: deleteAlbumToAssetsV1', error, stack);
rethrow;
}
}
@ -325,11 +326,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
} catch (error, stack) {
_logger.severe(
'Error: updateAlbumToAssetsV1 - $debugLabel',
error,
stackTrace,
stack,
);
rethrow;
}
@ -359,8 +360,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: updateMemoriesV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: updateMemoriesV1', error, stack);
rethrow;
}
}
@ -370,8 +371,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.memoryEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.memoryId)),
);
} catch (error, stackTrace) {
_logger.severe('Error: deleteMemoriesV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow;
}
}
@ -392,8 +393,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: updateMemoryAssetsV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: updateMemoryAssetsV1', error, stack);
rethrow;
}
}
@ -413,8 +414,49 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
} catch (error, stackTrace) {
_logger.severe('Error: deleteMemoryAssetsV1', error, stackTrace);
} catch (error, stack) {
_logger.severe('Error: deleteMemoryAssetsV1', error, stack);
rethrow;
}
}
Future<void> updateStacksV1(
Iterable<SyncStackV1> data, {
String debugLabel = 'user',
}) async {
try {
await _db.batch((batch) {
for (final stack in data) {
final companion = StackEntityCompanion(
createdAt: Value(stack.createdAt),
updatedAt: Value(stack.updatedAt),
ownerId: Value(stack.ownerId),
primaryAssetId: Value(stack.primaryAssetId),
);
batch.insert(
_db.stackEntity,
companion.copyWith(id: Value(stack.id)),
onConflict: DoUpdate((_) => companion),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateStacksV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteStacksV1(
Iterable<SyncStackDeleteV1> data, {
String debugLabel = 'user',
}) async {
try {
await _db.stackEntity.deleteWhere(
(row) => row.id.isIn(data.map((e) => e.stackId)),
);
} catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
rethrow;
}
}
@ -467,7 +509,7 @@ extension on String {
Duration? toDuration() {
try {
final parts = split(':')
.map((error) => double.parse(error).toInt())
.map((e) => double.parse(e).toInt())
.toList(growable: false);
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);

View file

@ -66,6 +66,9 @@ final _features = [
await db.remoteAlbumEntity.deleteAll();
await db.remoteAlbumUserEntity.deleteAll();
await db.remoteAlbumAssetEntity.deleteAll();
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
},
),
_Feature(

View file

@ -162,6 +162,10 @@ final _remoteStats = [
name: 'Memories Assets',
load: (db) => db.managers.memoryAssetEntity.count(),
),
_Stat(
name: 'Stacks',
load: (db) => db.managers.stackEntity.count(),
),
];
@RoutePage()

View file

@ -0,0 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final driftStackProvider = Provider<DriftStackRepository>(
(ref) => DriftStackRepository(ref.watch(driftProvider)),
);

View file

@ -40,6 +40,9 @@ class AuthRepository extends DatabaseRepository {
_drift.remoteAlbumEntity.deleteAll(),
_drift.remoteAlbumAssetEntity.deleteAll(),
_drift.remoteAlbumUserEntity.deleteAll(),
_drift.memoryEntity.deleteAll(),
_drift.memoryAssetEntity.deleteAll(),
_drift.stackEntity.deleteAll(),
]);
});
}

View file

@ -89,6 +89,18 @@ void main() {
.thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteMemoryAssetsV1(any()))
.thenAnswer(successHandler);
when(
() => mockSyncStreamRepo.updateStacksV1(
any(),
debugLabel: any(named: 'debugLabel'),
),
).thenAnswer(successHandler);
when(
() => mockSyncStreamRepo.deleteStacksV1(
any(),
debugLabel: any(named: 'debugLabel'),
),
).thenAnswer(successHandler);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,