mirror of
https://github.com/samsonjs/immich.git
synced 2026-03-25 09:15:56 +00:00
feat(mobile): asset face sync (#20022)
* feat(mobile): asset face sync * fix: lint --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
ab61bcfcc8
commit
ac44f6d1e0
22 changed files with 252 additions and 16 deletions
BIN
mobile/drift_schemas/main/drift_schema_v4.json
generated
Normal file
BIN
mobile/drift_schemas/main/drift_schema_v4.json
generated
Normal file
Binary file not shown.
98
mobile/lib/domain/models/asset_face.model.dart
Normal file
98
mobile/lib/domain/models/asset_face.model.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Model for an asset face stored in the server
|
||||
class AssetFace {
|
||||
final String id;
|
||||
final String assetId;
|
||||
final String? personId;
|
||||
final int imageWidth;
|
||||
final int imageHeight;
|
||||
final int boundingBoxX1;
|
||||
final int boundingBoxY1;
|
||||
final int boundingBoxX2;
|
||||
final int boundingBoxY2;
|
||||
final String sourceType;
|
||||
|
||||
const AssetFace({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
this.personId,
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
required this.boundingBoxX1,
|
||||
required this.boundingBoxY1,
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY2,
|
||||
required this.sourceType,
|
||||
});
|
||||
|
||||
AssetFace copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
String? personId,
|
||||
int? imageWidth,
|
||||
int? imageHeight,
|
||||
int? boundingBoxX1,
|
||||
int? boundingBoxY1,
|
||||
int? boundingBoxX2,
|
||||
int? boundingBoxY2,
|
||||
String? sourceType,
|
||||
}) {
|
||||
return AssetFace(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
personId: personId ?? this.personId,
|
||||
imageWidth: imageWidth ?? this.imageWidth,
|
||||
imageHeight: imageHeight ?? this.imageHeight,
|
||||
boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1,
|
||||
boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1,
|
||||
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''AssetFace {
|
||||
id: $id,
|
||||
assetId: $assetId,
|
||||
personId: ${personId ?? "<NA>"},
|
||||
imageWidth: $imageWidth,
|
||||
imageHeight: $imageHeight,
|
||||
boundingBoxX1: $boundingBoxX1,
|
||||
boundingBoxY1: $boundingBoxY1,
|
||||
boundingBoxX2: $boundingBoxX2,
|
||||
boundingBoxY2: $boundingBoxY2,
|
||||
sourceType: $sourceType,
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AssetFace other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.personId == personId &&
|
||||
other.imageWidth == imageWidth &&
|
||||
other.imageHeight == imageHeight &&
|
||||
other.boundingBoxX1 == boundingBoxX1 &&
|
||||
other.boundingBoxY1 == boundingBoxY1 &&
|
||||
other.boundingBoxX2 == boundingBoxX2 &&
|
||||
other.boundingBoxY2 == boundingBoxY2 &&
|
||||
other.sourceType == sourceType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
personId.hashCode ^
|
||||
imageWidth.hashCode ^
|
||||
imageHeight.hashCode ^
|
||||
boundingBoxX1.hashCode ^
|
||||
boundingBoxY1.hashCode ^
|
||||
boundingBoxX2.hashCode ^
|
||||
boundingBoxY2.hashCode ^
|
||||
sourceType.hashCode;
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +103,6 @@ class Person {
|
|||
final String ownerId;
|
||||
final String name;
|
||||
final String? faceAssetId;
|
||||
final String thumbnailPath;
|
||||
final bool isFavorite;
|
||||
final bool isHidden;
|
||||
final String? color;
|
||||
|
|
@ -116,7 +115,6 @@ class Person {
|
|||
required this.ownerId,
|
||||
required this.name,
|
||||
this.faceAssetId,
|
||||
required this.thumbnailPath,
|
||||
required this.isFavorite,
|
||||
required this.isHidden,
|
||||
required this.color,
|
||||
|
|
@ -130,7 +128,6 @@ class Person {
|
|||
String? ownerId,
|
||||
String? name,
|
||||
String? faceAssetId,
|
||||
String? thumbnailPath,
|
||||
bool? isFavorite,
|
||||
bool? isHidden,
|
||||
String? color,
|
||||
|
|
@ -143,7 +140,6 @@ class Person {
|
|||
ownerId: ownerId ?? this.ownerId,
|
||||
name: name ?? this.name,
|
||||
faceAssetId: faceAssetId ?? this.faceAssetId,
|
||||
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
color: color ?? this.color,
|
||||
|
|
@ -160,7 +156,6 @@ class Person {
|
|||
ownerId: $ownerId,
|
||||
name: $name,
|
||||
faceAssetId: ${faceAssetId ?? "<NA>"},
|
||||
thumbnailPath: $thumbnailPath,
|
||||
isFavorite: $isFavorite,
|
||||
isHidden: $isHidden,
|
||||
color: ${color ?? "<NA>"},
|
||||
|
|
@ -178,7 +173,6 @@ class Person {
|
|||
other.ownerId == ownerId &&
|
||||
other.name == name &&
|
||||
other.faceAssetId == faceAssetId &&
|
||||
other.thumbnailPath == thumbnailPath &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.color == color &&
|
||||
|
|
@ -193,7 +187,6 @@ class Person {
|
|||
ownerId.hashCode ^
|
||||
name.hashCode ^
|
||||
faceAssetId.hashCode ^
|
||||
thumbnailPath.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isHidden.hashCode ^
|
||||
color.hashCode ^
|
||||
|
|
|
|||
|
|
@ -244,6 +244,10 @@ class SyncStreamService {
|
|||
return _syncStreamRepository.updatePeopleV1(data.cast());
|
||||
case SyncEntityType.personDeleteV1:
|
||||
return _syncStreamRepository.deletePeopleV1(data.cast());
|
||||
case SyncEntityType.assetFaceV1:
|
||||
return _syncStreamRepository.updateAssetFacesV1(data.cast());
|
||||
case SyncEntityType.assetFaceDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
||||
default:
|
||||
_logger.warning("Unknown sync data type: $type");
|
||||
}
|
||||
|
|
|
|||
34
mobile/lib/infrastructure/entities/asset_face.entity.dart
Normal file
34
mobile/lib/infrastructure/entities/asset_face.entity.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class AssetFaceEntity extends Table with DriftDefaultsMixin {
|
||||
const AssetFaceEntity();
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get assetId =>
|
||||
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get personId => text()
|
||||
.nullable()
|
||||
.references(PersonEntity, #id, onDelete: KeyAction.setNull)();
|
||||
|
||||
IntColumn get imageWidth => integer()();
|
||||
|
||||
IntColumn get imageHeight => integer()();
|
||||
|
||||
IntColumn get boundingBoxX1 => integer()();
|
||||
|
||||
IntColumn get boundingBoxY1 => integer()();
|
||||
|
||||
IntColumn get boundingBoxX2 => integer()();
|
||||
|
||||
IntColumn get boundingBoxY2 => integer()();
|
||||
|
||||
TextColumn get sourceType => text()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
BIN
mobile/lib/infrastructure/entities/asset_face.entity.drift.dart
generated
Normal file
BIN
mobile/lib/infrastructure/entities/asset_face.entity.drift.dart
generated
Normal file
Binary file not shown.
|
|
@ -16,11 +16,8 @@ class PersonEntity extends Table with DriftDefaultsMixin {
|
|||
|
||||
TextColumn get name => text()();
|
||||
|
||||
// TODO: foreign key refering to asset faces
|
||||
TextColumn get faceAssetId => text().nullable()();
|
||||
|
||||
TextColumn get thumbnailPath => text()();
|
||||
|
||||
BoolColumn get isFavorite => boolean()();
|
||||
|
||||
BoolColumn get isHidden => boolean()();
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_face.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftAssetFaceRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftAssetFaceRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<AssetFace>> getAll() {
|
||||
return _db.assetFaceEntity
|
||||
.select()
|
||||
.map((assetFace) => assetFace.toDto())
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetFaceEntityData {
|
||||
AssetFace toDto() {
|
||||
return AssetFace(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
personId: personId,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
boundingBoxX1: boundingBoxX1,
|
||||
boundingBoxY1: boundingBoxY1,
|
||||
boundingBoxX2: boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2,
|
||||
sourceType: sourceType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:drift_flutter/drift_flutter.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
|
|
@ -56,6 +57,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
|||
MemoryAssetEntity,
|
||||
StackEntity,
|
||||
PersonEntity,
|
||||
AssetFaceEntity,
|
||||
],
|
||||
include: {
|
||||
'package:immich_mobile/infrastructure/entities/merged_asset.drift',
|
||||
|
|
@ -72,7 +74,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||
);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -94,6 +96,10 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||
// Removed foreign key constraint on stack.primaryAssetId
|
||||
await m.alterTable(TableMigration(v3.stackEntity));
|
||||
},
|
||||
from3To4: (m, v4) async {
|
||||
await m.alterTable(TableMigration(v4.personEntity));
|
||||
await m.create(v4.assetFaceEntity);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -26,7 +26,6 @@ extension on PersonEntityData {
|
|||
ownerId: ownerId,
|
||||
name: name,
|
||||
faceAssetId: faceAssetId,
|
||||
thumbnailPath: thumbnailPath,
|
||||
isFavorite: isFavorite,
|
||||
isHidden: isHidden,
|
||||
color: color,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class SyncApiRepository {
|
|||
SyncRequestType.partnerStacksV1,
|
||||
SyncRequestType.userMetadataV1,
|
||||
SyncRequestType.peopleV1,
|
||||
SyncRequestType.assetFacesV1,
|
||||
],
|
||||
).toJson(),
|
||||
);
|
||||
|
|
@ -176,6 +177,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
|||
SyncEntityType.userMetadataDeleteV1: SyncUserMetadataDeleteV1.fromJson,
|
||||
SyncEntityType.personV1: SyncPersonV1.fromJson,
|
||||
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
|
||||
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
|
||||
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
|
||||
};
|
||||
|
||||
class _SyncAckV1 {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
|||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
|
||||
|
|
@ -546,11 +547,62 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||
Iterable<SyncPersonDeleteV1> data,
|
||||
) async {
|
||||
try {
|
||||
await _db.personEntity.deleteWhere(
|
||||
(row) => row.id.isIn(data.map((e) => e.personId)),
|
||||
);
|
||||
await _db.batch((batch) {
|
||||
for (final person in data) {
|
||||
batch.deleteWhere(
|
||||
_db.personEntity,
|
||||
(row) => row.id.equals(person.personId),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: deletePeopleV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetFacesV1(Iterable<SyncAssetFaceV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetFace in data) {
|
||||
final companion = AssetFaceEntityCompanion(
|
||||
assetId: Value(assetFace.assetId),
|
||||
personId: Value(assetFace.personId),
|
||||
imageWidth: Value(assetFace.imageWidth),
|
||||
imageHeight: Value(assetFace.imageHeight),
|
||||
boundingBoxX1: Value(assetFace.boundingBoxX1),
|
||||
boundingBoxY1: Value(assetFace.boundingBoxY1),
|
||||
boundingBoxX2: Value(assetFace.boundingBoxX2),
|
||||
boundingBoxY2: Value(assetFace.boundingBoxY2),
|
||||
sourceType: Value(assetFace.sourceType),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.assetFaceEntity,
|
||||
companion.copyWith(id: Value(assetFace.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetFacesV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAssetFacesV1(Iterable<SyncAssetFaceDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetFace in data) {
|
||||
batch.deleteWhere(
|
||||
_db.assetFaceEntity,
|
||||
(row) => row.id.equals(assetFace.assetFaceId),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: deleteAssetFacesV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ final _features = [
|
|||
await db.memoryAssetEntity.deleteAll();
|
||||
await db.stackEntity.deleteAll();
|
||||
await db.personEntity.deleteAll();
|
||||
await db.assetFaceEntity.deleteAll();
|
||||
},
|
||||
),
|
||||
_Feature(
|
||||
|
|
|
|||
|
|
@ -170,6 +170,10 @@ final _remoteStats = [
|
|||
name: 'People',
|
||||
load: (db) => db.managers.personEntity.count(),
|
||||
),
|
||||
_Stat(
|
||||
name: 'AssetFaces',
|
||||
load: (db) => db.managers.assetFaceEntity.count(),
|
||||
),
|
||||
];
|
||||
|
||||
@RoutePage()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/asset_face.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
|
||||
final driftAssetFaceProvider = Provider<DriftAssetFaceRepository>(
|
||||
(ref) => DriftAssetFaceRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
|
@ -34,11 +34,12 @@ class AuthRepository extends DatabaseRepository {
|
|||
_drift.userMetadataEntity.deleteAll(),
|
||||
_drift.partnerEntity.deleteAll(),
|
||||
_drift.stackEntity.deleteAll(),
|
||||
_drift.personEntity.deleteAll(),
|
||||
_drift.assetFaceEntity.deleteAll(),
|
||||
]);
|
||||
// Drift deletions - parent entities
|
||||
await Future.wait([
|
||||
_drift.memoryEntity.deleteAll(),
|
||||
_drift.personEntity.deleteAll(),
|
||||
_drift.remoteAlbumEntity.deleteAll(),
|
||||
_drift.remoteAssetEntity.deleteAll(),
|
||||
_drift.userEntity.deleteAll(),
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@ void main() {
|
|||
.thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deletePeopleV1(any()))
|
||||
.thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.updateAssetFacesV1(any()))
|
||||
.thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any()))
|
||||
.thenAnswer(successHandler);
|
||||
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
|
|
|
|||
BIN
mobile/test/drift/main/generated/schema.dart
generated
BIN
mobile/test/drift/main/generated/schema.dart
generated
Binary file not shown.
BIN
mobile/test/drift/main/generated/schema_v4.dart
generated
Normal file
BIN
mobile/test/drift/main/generated/schema_v4.dart
generated
Normal file
Binary file not shown.
Loading…
Reference in a new issue