From d81ee18238e93806c7dca165ab96446614dd0ce1 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:04:37 +0530 Subject: [PATCH] sync remote asset metadata --- .../models/asset/asset_metadata.model.dart | 39 ++ .../domain/models/asset/metadata.model.dart | 20 - .../domain/services/sync_stream.service.dart | 4 + .../entities/local_asset.entity.dart | 1 + .../entities/local_asset.entity.drift.dart | 5 + .../remote_asset_metadata.entity.dart | 31 + .../remote_asset_metadata.entity.drift.dart | 647 ++++++++++++++++++ .../repositories/db.repository.dart | 5 + .../repositories/sync_api.repository.dart | 3 + .../repositories/sync_stream.repository.dart | 51 +- mobile/lib/services/upload.service.dart | 4 +- open-api/immich-openapi-specs.json | 19 +- 12 files changed, 799 insertions(+), 30 deletions(-) create mode 100644 mobile/lib/domain/models/asset/asset_metadata.model.dart delete mode 100644 mobile/lib/domain/models/asset/metadata.model.dart create mode 100644 mobile/lib/infrastructure/entities/remote_asset_metadata.entity.dart create mode 100644 mobile/lib/infrastructure/entities/remote_asset_metadata.entity.drift.dart diff --git a/mobile/lib/domain/models/asset/asset_metadata.model.dart b/mobile/lib/domain/models/asset/asset_metadata.model.dart new file mode 100644 index 0000000000..c9a10c4143 --- /dev/null +++ b/mobile/lib/domain/models/asset/asset_metadata.model.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +enum RemoteAssetMetadataKey { + mobileApp("mobile-app"); + + final String key; + + const RemoteAssetMetadataKey(this.key); + + factory RemoteAssetMetadataKey.fromKey(String key) { + switch (key) { + case "mobile-app": + return RemoteAssetMetadataKey.mobileApp; + default: + throw ArgumentError("Unknown AssetMetadataKey key: $key"); + } + } +} + +class RemoteAssetMetadata { + final String? cloudId; + + const RemoteAssetMetadata({this.cloudId}); + + Map toMap() { + final mobileAppValue = {}; + if (cloudId != null) { + mobileAppValue["iCloudId"] = cloudId; + } + + return { + "metadata": [ + {"key": RemoteAssetMetadataKey.mobileApp.key, "value": mobileAppValue}, + ], + }; + } + + String toJson() => json.encode(toMap()); +} diff --git a/mobile/lib/domain/models/asset/metadata.model.dart b/mobile/lib/domain/models/asset/metadata.model.dart deleted file mode 100644 index 1a4a93f7fd..0000000000 --- a/mobile/lib/domain/models/asset/metadata.model.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:convert'; - -class AssetMetadata { - final String? cloudId; - - const AssetMetadata({this.cloudId}); - - Map toMap() { - return { - "metadata": [ - { - "key": "mobile-app", - "value": cloudId != null ? {"iCloudId": cloudId} : {}, - }, - ], - }; - } - - String toJson() => json.encode(toMap()); -} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 5625635e49..17a8a8e296 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -117,6 +117,10 @@ class SyncStreamService { return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); + case SyncEntityType.assetMetadataV1: + return _syncStreamRepository.updateAssetsMetadataV1(data.cast()); + case SyncEntityType.assetMetadataDeleteV1: + return _syncStreamRepository.deleteAssetsMetadataV1(data.cast()); case SyncEntityType.partnerAssetV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner'); case SyncEntityType.partnerAssetBackfillV1: diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 29e1fb4629..54ccce8277 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)') +@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (cloud_id)') class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const LocalAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart index 23cdd92ec9..71d9d6ad72 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.drift.dart @@ -1055,3 +1055,8 @@ class LocalAssetEntityCompanion .toString(); } } + +i0.Index get idxLocalAssetCloudId => i0.Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (cloud_id)', +); diff --git a/mobile/lib/infrastructure/entities/remote_asset_metadata.entity.dart b/mobile/lib/infrastructure/entities/remote_asset_metadata.entity.dart new file mode 100644 index 0000000000..d6dd77f5de --- /dev/null +++ b/mobile/lib/infrastructure/entities/remote_asset_metadata.entity.dart @@ -0,0 +1,31 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class RemoteAssetMetadataEntity extends Table with DriftDefaultsMixin { + const RemoteAssetMetadataEntity(); + + TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get key => text().map(const RemoteAssetMetadataKeyConverter())(); + + BlobColumn get value => blob().map(assetMetadataConverter)(); + + @override + Set get primaryKey => {assetId, key}; +} + +class RemoteAssetMetadataKeyConverter extends TypeConverter { + const RemoteAssetMetadataKeyConverter(); + + @override + RemoteAssetMetadataKey fromSql(String fromDb) => RemoteAssetMetadataKey.fromKey(fromDb); + + @override + String toSql(RemoteAssetMetadataKey value) => value.key; +} + +final JsonTypeConverter2, Uint8List, Object?> assetMetadataConverter = TypeConverter.jsonb( + fromJson: (json) => json as Map, +); diff --git a/mobile/lib/infrastructure/entities/remote_asset_metadata.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset_metadata.entity.drift.dart new file mode 100644 index 0000000000..b10a1ba9fb --- /dev/null +++ b/mobile/lib/infrastructure/entities/remote_asset_metadata.entity.drift.dart @@ -0,0 +1,647 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/remote_asset_metadata.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart' + as i2; +import 'dart:typed_data' as i3; +import 'package:immich_mobile/infrastructure/entities/remote_asset_metadata.entity.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; + +typedef $$RemoteAssetMetadataEntityTableCreateCompanionBuilder = + i1.RemoteAssetMetadataEntityCompanion Function({ + required String assetId, + required i2.RemoteAssetMetadataKey key, + required Map value, + }); +typedef $$RemoteAssetMetadataEntityTableUpdateCompanionBuilder = + i1.RemoteAssetMetadataEntityCompanion Function({ + i0.Value assetId, + i0.Value key, + i0.Value> value, + }); + +final class $$RemoteAssetMetadataEntityTableReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.$RemoteAssetMetadataEntityTable, + i1.RemoteAssetMetadataEntityData + > { + $$RemoteAssetMetadataEntityTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') + .createAlias( + i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer(db) + .resultSet( + 'remote_asset_metadata_entity', + ) + .assetId, + i6.ReadDatabaseContainer( + db, + ).resultSet('remote_asset_entity').id, + ), + ); + + i5.$$RemoteAssetEntityTableProcessedTableManager get assetId { + final $_column = $_itemColumn('asset_id')!; + + final manager = i5 + .$$RemoteAssetEntityTableTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('remote_asset_entity'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$RemoteAssetMetadataEntityTableFilterComposer + extends + i0.Composer { + $$RemoteAssetMetadataEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnWithTypeConverterFilters< + i2.RemoteAssetMetadataKey, + i2.RemoteAssetMetadataKey, + String + > + get key => $composableBuilder( + column: $table.key, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + Map, + Map, + i3.Uint8List + > + get value => $composableBuilder( + column: $table.value, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i5.$$RemoteAssetEntityTableFilterComposer get assetId { + final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$RemoteAssetMetadataEntityTableOrderingComposer + extends + i0.Composer { + $$RemoteAssetMetadataEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => i0.ColumnOrderings(column), + ); + + i5.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i5.$$RemoteAssetEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$RemoteAssetMetadataEntityTableAnnotationComposer + extends + i0.Composer { + $$RemoteAssetMetadataEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumnWithTypeConverter + get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter, i3.Uint8List> + get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + i5.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i5.$$RemoteAssetEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$RemoteAssetMetadataEntityTableTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.$RemoteAssetMetadataEntityTable, + i1.RemoteAssetMetadataEntityData, + i1.$$RemoteAssetMetadataEntityTableFilterComposer, + i1.$$RemoteAssetMetadataEntityTableOrderingComposer, + i1.$$RemoteAssetMetadataEntityTableAnnotationComposer, + $$RemoteAssetMetadataEntityTableCreateCompanionBuilder, + $$RemoteAssetMetadataEntityTableUpdateCompanionBuilder, + ( + i1.RemoteAssetMetadataEntityData, + i1.$$RemoteAssetMetadataEntityTableReferences, + ), + i1.RemoteAssetMetadataEntityData, + i0.PrefetchHooks Function({bool assetId}) + > { + $$RemoteAssetMetadataEntityTableTableManager( + i0.GeneratedDatabase db, + i1.$RemoteAssetMetadataEntityTable table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$RemoteAssetMetadataEntityTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + i1.$$RemoteAssetMetadataEntityTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + i1.$$RemoteAssetMetadataEntityTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + i0.Value assetId = const i0.Value.absent(), + i0.Value key = + const i0.Value.absent(), + i0.Value> value = const i0.Value.absent(), + }) => i1.RemoteAssetMetadataEntityCompanion( + assetId: assetId, + key: key, + value: value, + ), + createCompanionCallback: + ({ + required String assetId, + required i2.RemoteAssetMetadataKey key, + required Map value, + }) => i1.RemoteAssetMetadataEntityCompanion.insert( + assetId: assetId, + key: key, + value: value, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$$RemoteAssetMetadataEntityTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({assetId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (assetId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.assetId, + referencedTable: i1 + .$$RemoteAssetMetadataEntityTableReferences + ._assetIdTable(db), + referencedColumn: i1 + .$$RemoteAssetMetadataEntityTableReferences + ._assetIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$RemoteAssetMetadataEntityTableProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$RemoteAssetMetadataEntityTable, + i1.RemoteAssetMetadataEntityData, + i1.$$RemoteAssetMetadataEntityTableFilterComposer, + i1.$$RemoteAssetMetadataEntityTableOrderingComposer, + i1.$$RemoteAssetMetadataEntityTableAnnotationComposer, + $$RemoteAssetMetadataEntityTableCreateCompanionBuilder, + $$RemoteAssetMetadataEntityTableUpdateCompanionBuilder, + ( + i1.RemoteAssetMetadataEntityData, + i1.$$RemoteAssetMetadataEntityTableReferences, + ), + i1.RemoteAssetMetadataEntityData, + i0.PrefetchHooks Function({bool assetId}) + >; + +class $RemoteAssetMetadataEntityTable extends i4.RemoteAssetMetadataEntity + with + i0.TableInfo< + $RemoteAssetMetadataEntityTable, + i1.RemoteAssetMetadataEntityData + > { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $RemoteAssetMetadataEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta( + 'assetId', + ); + @override + late final i0.GeneratedColumn assetId = i0.GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + @override + late final i0.GeneratedColumnWithTypeConverter< + i2.RemoteAssetMetadataKey, + String + > + key = + i0.GeneratedColumn( + 'key', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter( + i1.$RemoteAssetMetadataEntityTable.$converterkey, + ); + @override + late final i0.GeneratedColumnWithTypeConverter< + Map, + i3.Uint8List + > + value = + i0.GeneratedColumn( + 'value', + aliasedName, + false, + type: i0.DriftSqlType.blob, + requiredDuringInsert: true, + ).withConverter>( + i1.$RemoteAssetMetadataEntityTable.$convertervalue, + ); + @override + List get $columns => [assetId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_metadata_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, { + bool isInserting = false, + }) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('asset_id')) { + context.handle( + _assetIdMeta, + assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta), + ); + } else if (isInserting) { + context.missing(_assetIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {assetId, key}; + @override + i1.RemoteAssetMetadataEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.RemoteAssetMetadataEntityData( + assetId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + key: i1.$RemoteAssetMetadataEntityTable.$converterkey.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + ), + value: i1.$RemoteAssetMetadataEntityTable.$convertervalue.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.blob, + data['${effectivePrefix}value'], + )!, + ), + ); + } + + @override + $RemoteAssetMetadataEntityTable createAlias(String alias) { + return $RemoteAssetMetadataEntityTable(attachedDatabase, alias); + } + + static i0.TypeConverter $converterkey = + const i4.RemoteAssetMetadataKeyConverter(); + static i0.JsonTypeConverter2, i3.Uint8List, Object?> + $convertervalue = i4.assetMetadataConverter; + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetMetadataEntityData extends i0.DataClass + implements i0.Insertable { + final String assetId; + final i2.RemoteAssetMetadataKey key; + final Map value; + const RemoteAssetMetadataEntityData({ + required this.assetId, + required this.key, + required this.value, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = i0.Variable(assetId); + { + map['key'] = i0.Variable( + i1.$RemoteAssetMetadataEntityTable.$converterkey.toSql(key), + ); + } + { + map['value'] = i0.Variable( + i1.$RemoteAssetMetadataEntityTable.$convertervalue.toSql(value), + ); + } + return map; + } + + factory RemoteAssetMetadataEntityData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return RemoteAssetMetadataEntityData( + assetId: serializer.fromJson(json['assetId']), + key: serializer.fromJson(json['key']), + value: i1.$RemoteAssetMetadataEntityTable.$convertervalue.fromJson( + serializer.fromJson(json['value']), + ), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'key': serializer.toJson(key), + 'value': serializer.toJson( + i1.$RemoteAssetMetadataEntityTable.$convertervalue.toJson(value), + ), + }; + } + + i1.RemoteAssetMetadataEntityData copyWith({ + String? assetId, + i2.RemoteAssetMetadataKey? key, + Map? value, + }) => i1.RemoteAssetMetadataEntityData( + assetId: assetId ?? this.assetId, + key: key ?? this.key, + value: value ?? this.value, + ); + RemoteAssetMetadataEntityData copyWithCompanion( + i1.RemoteAssetMetadataEntityCompanion data, + ) { + return RemoteAssetMetadataEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetMetadataEntityData(') + ..write('assetId: $assetId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.RemoteAssetMetadataEntityData && + other.assetId == this.assetId && + other.key == this.key && + other.value == this.value); +} + +class RemoteAssetMetadataEntityCompanion + extends i0.UpdateCompanion { + final i0.Value assetId; + final i0.Value key; + final i0.Value> value; + const RemoteAssetMetadataEntityCompanion({ + this.assetId = const i0.Value.absent(), + this.key = const i0.Value.absent(), + this.value = const i0.Value.absent(), + }); + RemoteAssetMetadataEntityCompanion.insert({ + required String assetId, + required i2.RemoteAssetMetadataKey key, + required Map value, + }) : assetId = i0.Value(assetId), + key = i0.Value(key), + value = i0.Value(value); + static i0.Insertable custom({ + i0.Expression? assetId, + i0.Expression? key, + i0.Expression? value, + }) { + return i0.RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + i1.RemoteAssetMetadataEntityCompanion copyWith({ + i0.Value? assetId, + i0.Value? key, + i0.Value>? value, + }) { + return i1.RemoteAssetMetadataEntityCompanion( + assetId: assetId ?? this.assetId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = i0.Variable(assetId.value); + } + if (key.present) { + map['key'] = i0.Variable( + i1.$RemoteAssetMetadataEntityTable.$converterkey.toSql(key.value), + ); + } + if (value.present) { + map['value'] = i0.Variable( + i1.$RemoteAssetMetadataEntityTable.$convertervalue.toSql(value.value), + ); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetMetadataEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 8013c60d5c..e0d5fc991a 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -17,6 +17,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/remote_asset_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; @@ -50,6 +51,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { LocalAssetEntity, LocalAlbumAssetEntity, RemoteAssetEntity, + RemoteAssetMetadataEntity, RemoteExifEntity, RemoteAlbumEntity, RemoteAlbumAssetEntity, @@ -126,6 +128,9 @@ class Drift extends $Drift implements IDatabaseRepository { from8To9: (m, v9) async { // Add cloudId column to local_asset_entity await m.addColumn(v9.localAssetEntity, v9.localAssetEntity.cloudId); + await m.createIndex(v9.idxLocalAssetCloudId); + // Create new table + await m.createTable(v9.remoteAssetMetadataEntity); }, from8To9: (m, v9) async { await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId); diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 2175e77e82..025d038125 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -40,6 +40,7 @@ class SyncApiRepository { SyncRequestType.usersV1, SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, + SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetExifsV1, @@ -137,6 +138,8 @@ const _kResponseMap = { SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, + SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 52ffaabca9..e41b284cf3 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/asset_metadata.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'; @@ -15,13 +16,14 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift. import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; 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/remote_asset_metadata.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/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; -import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; +import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetMetadataKey; +import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey, AssetMetadataKey; class SyncStreamRepository extends DriftDatabaseRepository { final Logger _logger = Logger('DriftSyncStreamRepository'); @@ -178,6 +180,44 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future deleteAssetsMetadataV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final metadata in data) { + batch.deleteWhere( + _db.remoteAssetMetadataEntity, + (row) => row.assetId.equals(metadata.assetId) & row.key.equals(metadata.key.value), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteAssetsMetadataV1', error, stack); + rethrow; + } + } + + Future updateAssetsMetadataV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final metadata in data) { + final companion = RemoteAssetMetadataEntityCompanion( + key: Value(metadata.key.toRemoteAssetMetadataKey()), + value: Value(jsonDecode(metadata.value as String)), + ); + + batch.insert( + _db.remoteAssetMetadataEntity, + companion.copyWith(assetId: Value(metadata.assetId)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetsMetadataV1', error, stack); + rethrow; + } + } + Future deleteAlbumsV1(Iterable data) async { try { await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId))); @@ -573,3 +613,10 @@ extension on String { } } } + +extension on api.AssetMetadataKey { + RemoteAssetMetadataKey toRemoteAssetMetadataKey() => switch (this) { + api.AssetMetadataKey.mobileApp => RemoteAssetMetadataKey.mobileApp, + _ => throw Exception('Unknown AssetMetadataKey value: $this'), + }; +} diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index e4ac3df0cc..9e5836e4a6 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -6,8 +6,8 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/asset/metadata.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; @@ -365,7 +365,7 @@ class UploadService { 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', 'duration': '0', - 'metadata': AssetMetadata(cloudId: cloudId).toJson(), + 'metadata': RemoteAssetMetadata(cloudId: cloudId).toJson(), if (fields != null) ...fields, }; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f328c949b5..e148e7efae 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1855,7 +1855,7 @@ }, "/assets/bulk-upload-check": { "post": { - "description": "Checks if assets exist by checksums", + "description": "Checks if assets exist by checksums. This endpoint requires the `asset.upload` permission.", "operationId": "checkBulkUpload", "parameters": [], "requestBody": { @@ -1894,7 +1894,8 @@ "summary": "checkBulkUpload", "tags": [ "Assets" - ] + ], + "x-immich-permission": "asset.upload" } }, "/assets/device/{deviceId}": { @@ -14570,6 +14571,10 @@ "query": { "type": "string" }, + "queryAssetId": { + "format": "uuid", + "type": "string" + }, "rating": { "maximum": 5, "minimum": -1, @@ -14637,9 +14642,6 @@ "type": "boolean" } }, - "required": [ - "query" - ], "type": "object" }, "SourceType": { @@ -15415,6 +15417,10 @@ ], "type": "object" }, + "SyncCompleteV1": { + "properties": {}, + "type": "object" + }, "SyncEntityType": { "enum": [ "AuthUserV1", @@ -15462,7 +15468,8 @@ "UserMetadataV1", "UserMetadataDeleteV1", "SyncAckV1", - "SyncResetV1" + "SyncResetV1", + "SyncCompleteV1" ], "type": "string" },