diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 1b2c86026..3663d35b5 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart new file mode 100644 index 000000000..1f433b84f --- /dev/null +++ b/mobile/lib/domain/models/album/album.model.dart @@ -0,0 +1,79 @@ +enum AlbumAssetOrder { + // do not change this order! + asc, + desc, +} + +enum AlbumUserRole { + // do not change this order! + editor, + viewer, +} + +// Model for an album stored in the server +class Album { + final String id; + final String name; + final String ownerId; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final AlbumAssetOrder order; + + const Album({ + required this.id, + required this.name, + required this.ownerId, + required this.description, + required this.createdAt, + required this.updatedAt, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order, + }); + + @override + String toString() { + return '''Album { + id: $id, + name: $name, + ownerId: $ownerId, + description: $description, + createdAt: $createdAt, + updatedAt: $updatedAt, + isActivityEnabled: $isActivityEnabled, + order: $order, + thumbnailAssetId: ${thumbnailAssetId ?? ""} + }'''; + } + + @override + bool operator ==(Object other) { + if (other is! Album) return false; + if (identical(this, other)) return true; + return id == other.id && + name == other.name && + ownerId == other.ownerId && + description == other.description && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + thumbnailAssetId == other.thumbnailAssetId && + isActivityEnabled == other.isActivityEnabled && + order == other.order; + } + + @override + int get hashCode { + return id.hashCode ^ + name.hashCode ^ + ownerId.hashCode ^ + description.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + thumbnailAssetId.hashCode ^ + isActivityEnabled.hashCode ^ + order.hashCode; + } +} diff --git a/mobile/lib/domain/models/local_album.model.dart b/mobile/lib/domain/models/album/local_album.model.dart similarity index 100% rename from mobile/lib/domain/models/local_album.model.dart rename to mobile/lib/domain/models/album/local_album.model.dart diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 882bb801e..7a61dbc0c 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart'; diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index d612e8336..2160018df 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -76,11 +76,76 @@ class SyncStreamService { case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); case SyncEntityType.partnerAssetV1: - return _syncStreamRepository.updatePartnerAssetsV1(data.cast()); + return _syncStreamRepository.updateAssetsV1( + data.cast(), + debugLabel: 'partner', + ); + case SyncEntityType.partnerAssetBackfillV1: + return _syncStreamRepository.updateAssetsV1( + data.cast(), + debugLabel: 'partner backfill', + ); case SyncEntityType.partnerAssetDeleteV1: - return _syncStreamRepository.deletePartnerAssetsV1(data.cast()); + return _syncStreamRepository.deleteAssetsV1( + data.cast(), + debugLabel: "partner", + ); case SyncEntityType.partnerAssetExifV1: - return _syncStreamRepository.updatePartnerAssetsExifV1(data.cast()); + return _syncStreamRepository.updateAssetsExifV1( + data.cast(), + debugLabel: 'partner', + ); + case SyncEntityType.partnerAssetExifBackfillV1: + return _syncStreamRepository.updateAssetsExifV1( + data.cast(), + debugLabel: 'partner backfill', + ); + case SyncEntityType.albumV1: + return _syncStreamRepository.updateAlbumsV1(data.cast()); + case SyncEntityType.albumDeleteV1: + return _syncStreamRepository.deleteAlbumsV1(data.cast()); + case SyncEntityType.albumUserV1: + return _syncStreamRepository.updateAlbumUsersV1(data.cast()); + case SyncEntityType.albumUserBackfillV1: + return _syncStreamRepository.updateAlbumUsersV1( + data.cast(), + debugLabel: 'backfill', + ); + case SyncEntityType.albumUserDeleteV1: + return _syncStreamRepository.deleteAlbumUsersV1(data.cast()); + case SyncEntityType.albumAssetV1: + return _syncStreamRepository.updateAssetsV1( + data.cast(), + debugLabel: 'album', + ); + case SyncEntityType.albumAssetBackfillV1: + return _syncStreamRepository.updateAssetsV1( + data.cast(), + debugLabel: 'album backfill', + ); + case SyncEntityType.albumAssetExifV1: + return _syncStreamRepository.updateAssetsExifV1( + data.cast(), + debugLabel: 'album', + ); + case SyncEntityType.albumAssetExifBackfillV1: + return _syncStreamRepository.updateAssetsExifV1( + data.cast(), + debugLabel: 'album backfill', + ); + case SyncEntityType.albumToAssetV1: + return _syncStreamRepository.updateAlbumToAssetsV1(data.cast()); + case SyncEntityType.albumToAssetBackfillV1: + return _syncStreamRepository.updateAlbumToAssetsV1( + data.cast(), + debugLabel: 'backfill', + ); + case SyncEntityType.albumToAssetDeleteV1: + return _syncStreamRepository.deleteAlbumToAssetsV1(data.cast()); + // No-op. SyncAckV1 entities are checkpoints in the sync stream + // to acknowledge that the client has processed all the backfill events + case SyncEntityType.syncAckV1: + return; default: _logger.warning("Unknown sync data type: $type"); } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 225f7a89b..1dd2dfa15 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -45,6 +45,13 @@ class TimelineFactory { bucketSource: () => _timelineRepository.watchLocalBucket(albumId, groupBy: groupBy), ); + + TimelineService remoteAlbum({required String albumId}) => TimelineService( + assetSource: (offset, count) => _timelineRepository + .getRemoteBucketAssets(albumId, offset: offset, count: count), + bucketSource: () => + _timelineRepository.watchRemoteBucket(albumId, groupBy: groupBy), + ); } class TimelineService { diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index 9657173c3..398c5d4e4 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -1,5 +1,5 @@ import 'package:drift/drift.dart'; -import 'package:immich_mobile/domain/models/local_album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; class LocalAlbumEntity extends Table with DriftDefaultsMixin { diff --git a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart index ff6226ba3..06f65e25d 100644 Binary files a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart and b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_album.entity.dart b/mobile/lib/infrastructure/entities/remote_album.entity.dart new file mode 100644 index 000000000..377d67446 --- /dev/null +++ b/mobile/lib/infrastructure/entities/remote_album.entity.dart @@ -0,0 +1,34 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/album/album.model.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 RemoteAlbumEntity extends Table with DriftDefaultsMixin { + const RemoteAlbumEntity(); + + TextColumn get id => text()(); + + TextColumn get name => text()(); + + TextColumn get description => text().withDefault(const Constant(''))(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + TextColumn get ownerId => + text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get thumbnailAssetId => text() + .references(RemoteAssetEntity, #id, onDelete: KeyAction.setNull) + .nullable()(); + + BoolColumn get isActivityEnabled => + boolean().withDefault(const Constant(true))(); + + IntColumn get order => intEnum()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart new file mode 100644 index 000000000..bc13c8cb5 Binary files /dev/null and b/mobile/lib/infrastructure/entities/remote_album.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart new file mode 100644 index 000000000..1dcc336ed --- /dev/null +++ b/mobile/lib/infrastructure/entities/remote_album_asset.entity.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class RemoteAlbumAssetEntity extends Table with DriftDefaultsMixin { + const RemoteAlbumAssetEntity(); + + TextColumn get assetId => + text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get albumId => + text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {assetId, albumId}; +} diff --git a/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart new file mode 100644 index 000000000..ab50607c9 Binary files /dev/null and b/mobile/lib/infrastructure/entities/remote_album_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_album_user.entity.dart b/mobile/lib/infrastructure/entities/remote_album_user.entity.dart new file mode 100644 index 000000000..4198fb7e4 --- /dev/null +++ b/mobile/lib/infrastructure/entities/remote_album_user.entity.dart @@ -0,0 +1,20 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class RemoteAlbumUserEntity extends Table with DriftDefaultsMixin { + const RemoteAlbumUserEntity(); + + TextColumn get albumId => + text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get userId => + text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + IntColumn get role => intEnum()(); + + @override + Set get primaryKey => {albumId, userId}; +} diff --git a/mobile/lib/infrastructure/entities/remote_album_user.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_album_user.entity.drift.dart new file mode 100644 index 000000000..7ec1151a8 Binary files /dev/null and b/mobile/lib/infrastructure/entities/remote_album_user.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 3c7589949..bfe08346d 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @@ -34,3 +35,21 @@ class RemoteAssetEntity extends Table @override Set get primaryKey => {id}; } + +extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { + Asset toDto() => Asset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + height: height, + width: width, + thumbHash: thumbHash, + visibility: visibility, + localId: null, + ); +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 15b19f5c8..dbe491b03 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -8,6 +8,9 @@ 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'; import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; +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/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; @@ -40,6 +43,9 @@ class IsarDatabaseRepository implements IDatabaseRepository { LocalAlbumAssetEntity, RemoteAssetEntity, RemoteExifEntity, + RemoteAlbumEntity, + RemoteAlbumAssetEntity, + RemoteAlbumUserEntity, ], include: { 'package:immich_mobile/infrastructure/entities/merged_asset.drift', diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index d088e5420..69fd84b79 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index e5f8c7b52..33d61848d 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -1,6 +1,6 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart new file mode 100644 index 000000000..dd237c95b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -0,0 +1,45 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +enum SortRemoteAlbumsBy { id } + +class DriftRemoteAlbumRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftRemoteAlbumRepository(this._db) : super(_db); + + Future> getAll({Set sortBy = const {}}) { + final query = _db.remoteAlbumEntity.select(); + + if (sortBy.isNotEmpty) { + final orderings = >[]; + for (final sort in sortBy) { + orderings.add( + switch (sort) { + SortRemoteAlbumsBy.id => (row) => OrderingTerm.asc(row.id), + }, + ); + } + query.orderBy(orderings); + } + + return query.map((row) => row.toDto()).get(); + } +} + +extension on RemoteAlbumEntityData { + Album toDto() { + return Album( + id: id, + name: name, + ownerId: ownerId, + createdAt: createdAt, + updatedAt: updatedAt, + description: description, + thumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order, + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index f14773fc4..ccc79fa81 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -42,11 +42,16 @@ class SyncApiRepository { SyncStreamDto( types: [ SyncRequestType.usersV1, - SyncRequestType.partnersV1, SyncRequestType.assetsV1, - SyncRequestType.partnerAssetsV1, SyncRequestType.assetExifsV1, + SyncRequestType.partnersV1, + SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetExifsV1, + SyncRequestType.albumsV1, + SyncRequestType.albumUsersV1, + SyncRequestType.albumAssetsV1, + SyncRequestType.albumAssetExifsV1, + SyncRequestType.albumToAssetsV1, ], ).toJson(), ); @@ -135,6 +140,25 @@ const _kResponseMap = { SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, + SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson, + SyncEntityType.albumV1: SyncAlbumV1.fromJson, + SyncEntityType.albumDeleteV1: SyncAlbumDeleteV1.fromJson, + SyncEntityType.albumUserV1: SyncAlbumUserV1.fromJson, + SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson, + SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson, + SyncEntityType.albumAssetV1: SyncAssetV1.fromJson, + SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson, + SyncEntityType.albumAssetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson, + SyncEntityType.albumToAssetV1: SyncAlbumToAssetV1.fromJson, + SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson, + SyncEntityType.albumToAssetDeleteV1: SyncAlbumToAssetDeleteV1.fromJson, + SyncEntityType.syncAckV1: _SyncAckV1.fromJson, }; + +class _SyncAckV1 { + static _SyncAckV1? fromJson(dynamic _) => _SyncAckV1(); +} diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 56f1631ee..dfe65b698 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -1,13 +1,17 @@ import 'package:drift/drift.dart'; +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/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; +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/user.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; -import 'package:openapi/api.dart' hide AssetVisibility; +import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole; +import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole; class SyncStreamRepository extends DriftDatabaseRepository { final Logger _logger = Logger('DriftSyncStreamRepository'); @@ -17,16 +21,10 @@ class SyncStreamRepository extends DriftDatabaseRepository { Future deleteUsersV1(Iterable data) async { try { - await _db.batch((batch) { - for (final user in data) { - batch.delete( - _db.userEntity, - UserEntityCompanion(id: Value(user.userId)), - ); - } - }); + await _db.userEntity + .deleteWhere((row) => row.id.isIn(data.map((e) => e.userId))); } catch (error, stack) { - _logger.severe('Error while processing SyncUserDeleteV1', error, stack); + _logger.severe('Error: SyncUserDeleteV1', error, stack); rethrow; } } @@ -48,7 +46,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { } }); } catch (error, stack) { - _logger.severe('Error while processing SyncUserV1', error, stack); + _logger.severe('Error: SyncUserV1', error, stack); rethrow; } } @@ -67,7 +65,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { } }); } catch (e, s) { - _logger.severe('Error while processing SyncPartnerDeleteV1', e, s); + _logger.severe('Error: SyncPartnerDeleteV1', e, s); rethrow; } } @@ -90,67 +88,30 @@ class SyncStreamRepository extends DriftDatabaseRepository { } }); } catch (e, s) { - _logger.severe('Error while processing SyncPartnerV1', e, s); + _logger.severe('Error: SyncPartnerV1', e, s); rethrow; } } - Future deleteAssetsV1(Iterable data) async { + Future deleteAssetsV1( + Iterable data, { + String debugLabel = 'user', + }) async { try { - await _deleteAssetsV1(data); + await _db.remoteAssetEntity + .deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId))); } catch (e, s) { - _logger.severe('Error while processing deleteAssetsV1', e, s); + _logger.severe('Error: deleteAssetsV1 - $debugLabel', e, s); rethrow; } } - Future updateAssetsV1(Iterable data) async { + Future updateAssetsV1( + Iterable data, { + String debugLabel = 'user', + }) async { try { - await _updateAssetsV1(data); - } catch (e, s) { - _logger.severe('Error while processing updateAssetsV1', e, s); - rethrow; - } - } - - Future deletePartnerAssetsV1(Iterable data) async { - try { - await _deleteAssetsV1(data); - } catch (e, s) { - _logger.severe('Error while processing deletePartnerAssetsV1', e, s); - rethrow; - } - } - - Future updatePartnerAssetsV1(Iterable data) async { - try { - await _updateAssetsV1(data); - } catch (e, s) { - _logger.severe('Error while processing updatePartnerAssetsV1', e, s); - rethrow; - } - } - - Future updateAssetsExifV1(Iterable data) async { - try { - await _updateAssetExifV1(data); - } catch (e, s) { - _logger.severe('Error while processing updateAssetsExifV1', e, s); - rethrow; - } - } - - Future updatePartnerAssetsExifV1(Iterable data) async { - try { - await _updateAssetExifV1(data); - } catch (e, s) { - _logger.severe('Error while processing updatePartnerAssetsExifV1', e, s); - rethrow; - } - } - - Future _updateAssetsV1(Iterable data) => - _db.batch((batch) { + await _db.batch((batch) { for (final asset in data) { final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), @@ -175,19 +136,18 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); + } catch (e, s) { + _logger.severe('Error: updateAssetsV1 - $debugLabel', e, s); + rethrow; + } + } - Future _deleteAssetsV1(Iterable assets) => - _db.batch((batch) { - for (final asset in assets) { - batch.delete( - _db.remoteAssetEntity, - RemoteAssetEntityCompanion(id: Value(asset.assetId)), - ); - } - }); - - Future _updateAssetExifV1(Iterable data) => - _db.batch((batch) { + Future updateAssetsExifV1( + Iterable data, { + String debugLabel = 'user', + }) async { + try { + await _db.batch((batch) { for (final exif in data) { final companion = RemoteExifEntityCompanion( city: Value(exif.city), @@ -219,6 +179,141 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); + } catch (e, s) { + _logger.severe('Error: updateAssetsExifV1 - $debugLabel', e, s); + rethrow; + } + } + + Future deleteAlbumsV1(Iterable data) async { + try { + await _db.remoteAlbumEntity + .deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId))); + } catch (e, s) { + _logger.severe('Error: deleteAlbumsV1', e, s); + rethrow; + } + } + + Future updateAlbumsV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final album in data) { + final companion = RemoteAlbumEntityCompanion( + name: Value(album.name), + description: Value(album.description), + isActivityEnabled: Value(album.isActivityEnabled), + order: Value(album.order.toAlbumAssetOrder()), + thumbnailAssetId: Value(album.thumbnailAssetId), + ownerId: Value(album.ownerId), + createdAt: Value(album.createdAt), + updatedAt: Value(album.updatedAt), + ); + + batch.insert( + _db.remoteAlbumEntity, + companion.copyWith(id: Value(album.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (e, s) { + _logger.severe('Error: updateAlbumsV1', e, s); + rethrow; + } + } + + Future deleteAlbumUsersV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final album in data) { + batch.delete( + _db.remoteAlbumUserEntity, + RemoteAlbumUserEntityCompanion( + albumId: Value(album.albumId), + userId: Value(album.userId), + ), + ); + } + }); + } catch (e, s) { + _logger.severe('Error: deleteAlbumUsersV1', e, s); + rethrow; + } + } + + Future updateAlbumUsersV1( + Iterable data, { + String debugLabel = 'user', + }) async { + try { + await _db.batch((batch) { + for (final album in data) { + final companion = RemoteAlbumUserEntityCompanion( + role: Value(album.role.toAlbumUserRole()), + ); + + batch.insert( + _db.remoteAlbumUserEntity, + companion.copyWith( + albumId: Value(album.albumId), + userId: Value(album.userId), + ), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (e, s) { + _logger.severe('Error: updateAlbumUsersV1 - $debugLabel', e, s); + rethrow; + } + } + + Future deleteAlbumToAssetsV1( + Iterable data, + ) async { + try { + await _db.batch((batch) { + for (final album in data) { + batch.delete( + _db.remoteAlbumAssetEntity, + RemoteAlbumAssetEntityCompanion( + albumId: Value(album.albumId), + assetId: Value(album.assetId), + ), + ); + } + }); + } catch (e, s) { + _logger.severe('Error: deleteAlbumToAssetsV1', e, s); + rethrow; + } + } + + Future updateAlbumToAssetsV1( + Iterable data, { + String debugLabel = 'user', + }) async { + try { + await _db.batch((batch) { + for (final album in data) { + final companion = RemoteAlbumAssetEntityCompanion( + albumId: Value(album.albumId), + assetId: Value(album.assetId), + ); + + batch.insert( + _db.remoteAlbumAssetEntity, + companion, + onConflict: DoNothing(), + ); + } + }); + } catch (e, s) { + _logger.severe('Error: updateAlbumToAssetsV1 - $debugLabel', e, s); + rethrow; + } + } } extension on AssetTypeEnum { @@ -231,6 +326,22 @@ extension on AssetTypeEnum { }; } +extension on AssetOrder { + AlbumAssetOrder toAlbumAssetOrder() => switch (this) { + AssetOrder.asc => AlbumAssetOrder.asc, + AssetOrder.desc => AlbumAssetOrder.desc, + _ => throw Exception('Unknown AssetOrder value: $this'), + }; +} + +extension on api.AlbumUserRole { + AlbumUserRole toAlbumUserRole() => switch (this) { + api.AlbumUserRole.editor => AlbumUserRole.editor, + api.AlbumUserRole.viewer => AlbumUserRole.viewer, + _ => throw Exception('Unknown AlbumUserRole value: $this'), + }; +} + extension on api.AssetVisibility { AssetVisibility toAssetVisibility() => switch (this) { api.AssetVisibility.timeline => AssetVisibility.timeline, diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index add327cf0..7f5591c04 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -139,6 +140,62 @@ class DriftTimelineRepository extends DriftDatabaseRepository { .map((row) => row.readTable(_db.localAssetEntity).toDto()) .get(); } + + Stream> watchRemoteBucket( + String albumId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + return _db.remoteAlbumAssetEntity + .count(where: (row) => row.albumId.equals(albumId)) + .map(_generateBuckets) + .watchSingle(); + } + + final assetCountExp = _db.remoteAssetEntity.id.count(); + final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.assetId + .equalsExp(_db.remoteAssetEntity.id), + ), + ]) + ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + Future> getRemoteBucketAssets( + String albumId, { + required int offset, + required int count, + }) { + final query = _db.remoteAssetEntity.select().join( + [ + innerJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.assetId + .equalsExp(_db.remoteAssetEntity.id), + ), + ], + ) + ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) + ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) + ..limit(count, offset: offset); + return query + .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) + .get(); + } } extension on Expression { diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index c53b7fe0d..f10c042e1 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -63,6 +63,9 @@ final _features = [ final db = ref.read(driftProvider); await db.remoteAssetEntity.deleteAll(); await db.remoteExifEntity.deleteAll(); + await db.remoteAlbumEntity.deleteAll(); + await db.remoteAlbumUserEntity.deleteAll(); + await db.remoteAlbumAssetEntity.deleteAll(); }, ), _Feature( diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index cc1fd0ae0..10d09f8de 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -40,7 +40,10 @@ class _Summary extends StatelessWidget { } else if (snapshot.hasError) { subtitle = const Icon(Icons.error_rounded); } else { - subtitle = Text('${snapshot.data ?? 0}'); + subtitle = Text( + '${snapshot.data ?? 0}', + style: ctx.textTheme.bodyLarge, + ); } return ListTile( leading: leading, @@ -147,6 +150,10 @@ final _remoteStats = [ name: 'Exif Entities', load: (db) => db.managers.remoteExifEntity.count(), ), + _Stat( + name: 'Remote Albums', + load: (db) => db.managers.remoteAlbumEntity.count(), + ), ]; @RoutePage() @@ -160,6 +167,7 @@ class RemoteMediaSummaryPage extends StatelessWidget { body: Consumer( builder: (ctx, ref, __) { final db = ref.watch(driftProvider); + final albumsFuture = ref.watch(remoteAlbumRepository).getAll(); return CustomScrollView( slivers: [ @@ -171,6 +179,49 @@ class RemoteMediaSummaryPage extends StatelessWidget { }, itemCount: _remoteStats.length, ), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(), + Padding( + padding: const EdgeInsets.only(left: 15), + child: Text( + "Album summary", + style: ctx.textTheme.titleMedium, + ), + ), + ], + ), + ), + FutureBuilder( + future: albumsFuture, + builder: (_, snap) { + final albums = snap.data ?? []; + if (albums.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + albums.sortBy((a) => a.name); + return SliverList.builder( + itemBuilder: (_, index) { + final album = albums[index]; + final countFuture = db.managers.remoteAlbumAssetEntity + .filter((f) => f.albumId.id.equals(album.id)) + .count(); + return _Summary( + leading: const Icon(Icons.photo_album_rounded), + name: album.name, + countFuture: countFuture, + onTap: () => context.router.push( + RemoteTimelineRoute(albumId: album.id), + ), + ); + }, + itemCount: albums.length, + ); + }, + ), ], ); }, diff --git a/mobile/lib/presentation/pages/dev/remote_timeline.page.dart b/mobile/lib/presentation/pages/dev/remote_timeline.page.dart new file mode 100644 index 000000000..4965359ad --- /dev/null +++ b/mobile/lib/presentation/pages/dev/remote_timeline.page.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +@RoutePage() +class RemoteTimelinePage extends StatelessWidget { + final String albumId; + + const RemoteTimelinePage({super.key, required this.albumId}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .remoteAlbum(albumId: albumId); + ref.onDispose(() => unawaited(timelineService.dispose())); + return timelineService; + }, + ), + ], + child: const Timeline(), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 6a4807319..b9dd21204 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -1,7 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; final localAlbumRepository = Provider( (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), ); + +final remoteAlbumRepository = Provider( + (ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index 140328072..3ad8e3458 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -34,6 +34,12 @@ class AuthRepository extends DatabaseRepository { db.users.clear(), _drift.remoteAssetEntity.deleteAll(), _drift.remoteExifEntity.deleteAll(), + _drift.userEntity.deleteAll(), + _drift.userMetadataEntity.deleteAll(), + _drift.partnerEntity.deleteAll(), + _drift.remoteAlbumEntity.deleteAll(), + _drift.remoteAlbumAssetEntity.deleteAll(), + _drift.remoteAlbumUserEntity.deleteAll(), ]); }); } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 33631f85d..708171896 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -68,6 +68,7 @@ import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.da import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -365,6 +366,10 @@ class AppRouter extends RootStackRouter { page: MainTimelineRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: RemoteTimelineRoute.page, + guards: [_authGuard, _duplicateGuard], + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 797b519dd..a5c2c5861 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1425,6 +1425,43 @@ class RemoteMediaSummaryRoute extends PageRouteInfo { ); } +/// generated route for +/// [RemoteTimelinePage] +class RemoteTimelineRoute extends PageRouteInfo { + RemoteTimelineRoute({ + Key? key, + required String albumId, + List? children, + }) : super( + RemoteTimelineRoute.name, + args: RemoteTimelineRouteArgs(key: key, albumId: albumId), + initialChildren: children, + ); + + static const String name = 'RemoteTimelineRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return RemoteTimelinePage(key: args.key, albumId: args.albumId); + }, + ); +} + +class RemoteTimelineRouteArgs { + const RemoteTimelineRouteArgs({this.key, required this.albumId}); + + final Key? key; + + final String albumId; + + @override + String toString() { + return 'RemoteTimelineRouteArgs{key: $key, albumId: $albumId}'; + } +} + /// generated route for /// [SearchPage] class SearchRoute extends PageRouteInfo { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 238a19e5d..b09f36548 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b9649b0ba..df61b8ab0 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index dd37b628a..18b2c9202 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/sync_album_to_asset_delete_v1.dart b/mobile/openapi/lib/model/sync_album_to_asset_delete_v1.dart new file mode 100644 index 000000000..d18c850b2 Binary files /dev/null and b/mobile/openapi/lib/model/sync_album_to_asset_delete_v1.dart differ diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 2922358ff..a0b61bcaf 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -59,16 +59,28 @@ void main() { .thenAnswer(successHandler); when(() => mockSyncStreamRepo.updateAssetsV1(any())) .thenAnswer(successHandler); + when( + () => mockSyncStreamRepo.updateAssetsV1( + any(), + debugLabel: any(named: 'debugLabel'), + ), + ).thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteAssetsV1(any())) .thenAnswer(successHandler); + when( + () => mockSyncStreamRepo.deleteAssetsV1( + any(), + debugLabel: any(named: 'debugLabel'), + ), + ).thenAnswer(successHandler); when(() => mockSyncStreamRepo.updateAssetsExifV1(any())) .thenAnswer(successHandler); - when(() => mockSyncStreamRepo.updatePartnerAssetsV1(any())) - .thenAnswer(successHandler); - when(() => mockSyncStreamRepo.deletePartnerAssetsV1(any())) - .thenAnswer(successHandler); - when(() => mockSyncStreamRepo.updatePartnerAssetsExifV1(any())) - .thenAnswer(successHandler); + when( + () => mockSyncStreamRepo.updateAssetsExifV1( + any(), + debugLabel: any(named: 'debugLabel'), + ), + ).thenAnswer(successHandler); sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, diff --git a/mobile/test/fixtures/album.stub.dart b/mobile/test/fixtures/album.stub.dart index 1432d3590..1e79f62fa 100644 --- a/mobile/test/fixtures/album.stub.dart +++ b/mobile/test/fixtures/album.stub.dart @@ -1,4 +1,4 @@ -import 'package:immich_mobile/domain/models/local_album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; diff --git a/mobile/test/infrastructure/repositories/local_album_repository_test.dart b/mobile/test/infrastructure/repositories/local_album_repository_test.dart index f6c82c1be..bab25de52 100644 --- a/mobile/test/infrastructure/repositories/local_album_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_album_repository_test.dart @@ -1,7 +1,7 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/domain/models/local_album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; diff --git a/mobile/test/test_utils/medium_factory.dart b/mobile/test/test_utils/medium_factory.dart index affe9c9b3..8dafc564c 100644 --- a/mobile/test/test_utils/medium_factory.dart +++ b/mobile/test/test_utils/medium_factory.dart @@ -1,7 +1,7 @@ import 'dart:math'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b63b50338..129c12028 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13450,6 +13450,21 @@ ], "type": "object" }, + "SyncAlbumToAssetDeleteV1": { + "properties": { + "albumId": { + "type": "string" + }, + "assetId": { + "type": "string" + } + }, + "required": [ + "albumId", + "assetId" + ], + "type": "object" + }, "SyncAlbumToAssetV1": { "properties": { "albumId": { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index b552f52a3..7385edf40 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -198,6 +198,7 @@ const responseDtos = [ SyncAlbumUserV1, SyncAlbumUserDeleteV1, SyncAlbumToAssetV1, + SyncAlbumToAssetDeleteV1, SyncAckV1, ];