diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index 5db184bf76..e633c8694d 100644 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -75,8 +75,8 @@ describe('/timeline', () => { expect(status).toBe(200); expect(body).toEqual( expect.arrayContaining([ - { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + { count: 3, timeBucket: '1970-02-01' }, + { count: 1, timeBucket: '1970-01-01' }, ]), ); }); @@ -167,7 +167,8 @@ describe('/timeline', () => { isImage: [], isTrashed: [], livePhotoVideoId: [], - localDateTime: [], + fileCreatedAt: [], + localOffsetHours: [], ownerId: [], projectionType: [], ratio: [], @@ -204,7 +205,8 @@ describe('/timeline', () => { isImage: [], isTrashed: [], livePhotoVideoId: [], - localDateTime: [], + fileCreatedAt: [], + localOffsetHours: [], ownerId: [], projectionType: [], ratio: [], diff --git a/i18n/en.json b/i18n/en.json index 418c26a2b3..849f96e4a5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -402,6 +402,9 @@ "album_with_link_access": "Let anyone with the link see photos and people in this album.", "albums": "Albums", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}", + "albums_default_sort_order": "Default album sort order", + "albums_default_sort_order_description": "Initial asset sort order when creating new albums.", + "albums_feature_description": "Collections of assets that can be shared with other users.", "all": "All", "all_albums": "All albums", "all_people": "All people", diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index dc81c10dec..4c06edc8c9 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -56,6 +56,7 @@ custom_lint: allowed: # required / wanted - 'lib/infrastructure/repositories/album_media.repository.dart' + - 'lib/infrastructure/repositories/storage.repository.dart' - 'lib/repositories/{album,asset,file}_media.repository.dart' # acceptable exceptions for the time being - lib/entities/asset.entity.dart # to provide local AssetEntity for now diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index f4dbda730b..18a788903a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -247,6 +247,7 @@ interface NativeSyncApi { fun getAlbums(): List fun getAssetsCountSince(albumId: String, timestamp: Long): Long fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List + fun hashPaths(paths: List): List companion object { /** The codec used by NativeSyncApi. */ @@ -388,6 +389,23 @@ interface NativeSyncApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pathsArg = args[0] as List + val wrapped: List = try { + listOf(api.hashPaths(pathsArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 2322855307..70fc045d5b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -4,7 +4,10 @@ import android.annotation.SuppressLint import android.content.Context import android.database.Cursor import android.provider.MediaStore +import android.util.Log import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest sealed class AssetResult { data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult() @@ -16,6 +19,8 @@ open class NativeSyncApiImplBase(context: Context) { private val ctx: Context = context.applicationContext companion object { + private const val TAG = "NativeSyncApiImplBase" + const val MEDIA_SELECTION = "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" val MEDIA_SELECTION_ARGS = arrayOf( @@ -34,6 +39,8 @@ open class NativeSyncApiImplBase(context: Context) { MediaStore.MediaColumns.BUCKET_ID, MediaStore.MediaColumns.DURATION ) + + const val HASH_BUFFER_SIZE = 2 * 1024 * 1024 } protected fun getCursor( @@ -174,4 +181,24 @@ open class NativeSyncApiImplBase(context: Context) { .mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset } .toList() } + + fun hashPaths(paths: List): List { + val buffer = ByteArray(HASH_BUFFER_SIZE) + val digest = MessageDigest.getInstance("SHA-1") + + return paths.map { path -> + try { + FileInputStream(path).use { file -> + var bytesRead: Int + while (file.read(buffer).also { bytesRead = it } > 0) { + digest.update(buffer, 0, bytesRead) + } + } + digest.digest() + } catch (e: Exception) { + Log.w(TAG, "Failed to hash file $path: $e") + null + } + } + } } diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 5cdec3d924..ee8e41aea2 100644 --- a/mobile/drift_schemas/main/drift_schema_v1.json +++ b/mobile/drift_schemas/main/drift_schema_v1.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4,3],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":6,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":7,"references":[6],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":9,"references":[6],"type":"index","data":{"on":6,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4,3],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":6,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":7,"references":[6],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":9,"references":[6],"type":"index","data":{"on":6,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}}]} \ No newline at end of file diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 0d7a302688..eb765337c3 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -307,6 +307,7 @@ protocol NativeSyncApi { func getAlbums() throws -> [PlatformAlbum] func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] + func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -442,5 +443,22 @@ class NativeSyncApiSetup { } else { getAssetsForAlbumChannel.setMessageHandler(nil) } + let hashPathsChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + hashPathsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let pathsArg = args[0] as! [String] + do { + let result = try api.hashPaths(paths: pathsArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + hashPathsChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 5d2f08691d..06c958b88a 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -1,4 +1,5 @@ import Photos +import CryptoKit struct AssetWrapper: Hashable, Equatable { let asset: PlatformAsset @@ -34,6 +35,8 @@ class NativeSyncApiImpl: NativeSyncApi { private let changeTokenKey = "immich:changeToken" private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] + private let hashBufferSize = 2 * 1024 * 1024 + init(with defaults: UserDefaults = .standard) { self.defaults = defaults } @@ -243,4 +246,24 @@ class NativeSyncApiImpl: NativeSyncApi { } return assets } + + func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] { + return paths.map { path in + guard let file = FileHandle(forReadingAtPath: path) else { + print("Cannot open file: \(path)") + return nil + } + + var hasher = Insecure.SHA1() + while autoreleasepool(invoking: { + let chunk = file.readData(ofLength: hashBufferSize) + guard !chunk.isEmpty else { return false } + hasher.update(data: chunk) + return true + }) { } + + let digest = hasher.finalize() + return FlutterStandardTypedData(bytes: Data(digest)) + } + } } diff --git a/mobile/lib/domain/interfaces/local_album.interface.dart b/mobile/lib/domain/interfaces/local_album.interface.dart index 35cfad4455..d7b38c567f 100644 --- a/mobile/lib/domain/interfaces/local_album.interface.dart +++ b/mobile/lib/domain/interfaces/local_album.interface.dart @@ -29,6 +29,8 @@ abstract interface class ILocalAlbumRepository implements IDatabaseRepository { String albumId, Iterable assetIdsToKeep, ); + + Future> getAssetsToHash(String albumId); } enum SortLocalAlbumsBy { id } diff --git a/mobile/lib/domain/interfaces/local_asset.interface.dart b/mobile/lib/domain/interfaces/local_asset.interface.dart new file mode 100644 index 0000000000..5792ebe5d9 --- /dev/null +++ b/mobile/lib/domain/interfaces/local_asset.interface.dart @@ -0,0 +1,6 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +abstract interface class ILocalAssetRepository implements IDatabaseRepository { + Future updateHashes(Iterable hashes); +} diff --git a/mobile/lib/domain/interfaces/storage.interface.dart b/mobile/lib/domain/interfaces/storage.interface.dart new file mode 100644 index 0000000000..ea6513e7f2 --- /dev/null +++ b/mobile/lib/domain/interfaces/storage.interface.dart @@ -0,0 +1,7 @@ +import 'dart:io'; + +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +abstract interface class IStorageRepository { + Future getFileForAsset(LocalAsset asset); +} diff --git a/mobile/lib/domain/models/local_album.model.dart b/mobile/lib/domain/models/local_album.model.dart index 95c56627bb..ee27d91a71 100644 --- a/mobile/lib/domain/models/local_album.model.dart +++ b/mobile/lib/domain/models/local_album.model.dart @@ -1,13 +1,19 @@ enum BackupSelection { - none, - selected, - excluded, + none._(1), + selected._(0), + excluded._(2); + + // Used to sort albums based on the backupSelection + // selected -> none -> excluded + final int sortOrder; + const BackupSelection._(this.sortOrder); } class LocalAlbum { final String id; final String name; final DateTime updatedAt; + final bool isIosSharedAlbum; final int assetCount; final BackupSelection backupSelection; @@ -18,6 +24,7 @@ class LocalAlbum { required this.updatedAt, this.assetCount = 0, this.backupSelection = BackupSelection.none, + this.isIosSharedAlbum = false, }); LocalAlbum copyWith({ @@ -26,6 +33,7 @@ class LocalAlbum { DateTime? updatedAt, int? assetCount, BackupSelection? backupSelection, + bool? isIosSharedAlbum, }) { return LocalAlbum( id: id ?? this.id, @@ -33,6 +41,7 @@ class LocalAlbum { updatedAt: updatedAt ?? this.updatedAt, assetCount: assetCount ?? this.assetCount, backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, ); } @@ -45,7 +54,8 @@ class LocalAlbum { other.name == name && other.updatedAt == updatedAt && other.assetCount == assetCount && - other.backupSelection == backupSelection; + other.backupSelection == backupSelection && + other.isIosSharedAlbum == isIosSharedAlbum; } @override @@ -54,7 +64,8 @@ class LocalAlbum { name.hashCode ^ updatedAt.hashCode ^ assetCount.hashCode ^ - backupSelection.hashCode; + backupSelection.hashCode ^ + isIosSharedAlbum.hashCode; } @override @@ -65,6 +76,7 @@ name: $name, updatedAt: $updatedAt, assetCount: $assetCount, backupSelection: $backupSelection, +isIosSharedAlbum: $isIosSharedAlbum }'''; } } diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart new file mode 100644 index 0000000000..9820453db1 --- /dev/null +++ b/mobile/lib/domain/services/hash.service.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/storage.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart'; +import 'package:logging/logging.dart'; + +class HashService { + final int batchSizeLimit; + final int batchFileLimit; + final ILocalAlbumRepository _localAlbumRepository; + final ILocalAssetRepository _localAssetRepository; + final IStorageRepository _storageRepository; + final NativeSyncApi _nativeSyncApi; + final _log = Logger('HashService'); + + HashService({ + required ILocalAlbumRepository localAlbumRepository, + required ILocalAssetRepository localAssetRepository, + required IStorageRepository storageRepository, + required NativeSyncApi nativeSyncApi, + this.batchSizeLimit = kBatchHashSizeLimit, + this.batchFileLimit = kBatchHashFileLimit, + }) : _localAlbumRepository = localAlbumRepository, + _localAssetRepository = localAssetRepository, + _storageRepository = storageRepository, + _nativeSyncApi = nativeSyncApi; + + Future hashAssets() async { + final Stopwatch stopwatch = Stopwatch()..start(); + // Sorted by backupSelection followed by isCloud + final localAlbums = await _localAlbumRepository.getAll(); + localAlbums.sort((a, b) { + final backupComparison = + a.backupSelection.sortOrder.compareTo(b.backupSelection.sortOrder); + + if (backupComparison != 0) { + return backupComparison; + } + + // Local albums come before iCloud albums + return (a.isIosSharedAlbum ? 1 : 0).compareTo(b.isIosSharedAlbum ? 1 : 0); + }); + + for (final album in localAlbums) { + final assetsToHash = + await _localAlbumRepository.getAssetsToHash(album.id); + if (assetsToHash.isNotEmpty) { + await _hashAssets(assetsToHash); + } + } + + stopwatch.stop(); + _log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); + DLog.log("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); + } + + /// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB + /// with hash for those that were successfully hashed. Hashes are looked up in a table + /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. + Future _hashAssets(List assetsToHash) async { + int bytesProcessed = 0; + final toHash = <_AssetToPath>[]; + + for (final asset in assetsToHash) { + final file = await _storageRepository.getFileForAsset(asset); + if (file == null) { + continue; + } + + bytesProcessed += await file.length(); + toHash.add(_AssetToPath(asset: asset, path: file.path)); + + if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) { + await _processBatch(toHash); + toHash.clear(); + bytesProcessed = 0; + } + } + + await _processBatch(toHash); + } + + /// Processes a batch of assets. + Future _processBatch(List<_AssetToPath> toHash) async { + if (toHash.isEmpty) { + return; + } + + _log.fine("Hashing ${toHash.length} files"); + + final hashed = []; + final hashes = + await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList()); + + for (final (index, hash) in hashes.indexed) { + final asset = toHash[index].asset; + if (hash?.length == 20) { + hashed.add(asset.copyWith(checksum: base64.encode(hash!))); + } else { + _log.warning("Failed to hash file ${asset.id}"); + } + } + + _log.fine("Hashed ${hashed.length}/${toHash.length} assets"); + DLog.log("Hashed ${hashed.length}/${toHash.length} assets"); + + await _localAssetRepository.updateHashes(hashed); + } +} + +class _AssetToPath { + final LocalAsset asset; + final String path; + + const _AssetToPath({required this.asset, required this.path}); +} diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index e07595b6db..e39999f222 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -365,6 +365,7 @@ extension on Iterable { (e) => LocalAsset( id: e.id, name: e.name, + checksum: null, type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, createdAt: e.createdAt == null ? DateTime.now() diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 6a694ee44a..c8d2e2b624 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -7,6 +7,7 @@ import 'package:worker_manager/worker_manager.dart'; class BackgroundSyncManager { Cancelable? _syncTask; Cancelable? _deviceAlbumSyncTask; + Cancelable? _hashTask; BackgroundSyncManager(); @@ -45,6 +46,20 @@ class BackgroundSyncManager { }); } +// No need to cancel the task, as it can also be run when the user logs out + Future hashAssets() { + if (_hashTask != null) { + return _hashTask!.future; + } + + _hashTask = runInIsolateGentle( + computation: (ref) => ref.read(hashServiceProvider).hashAssets(), + ); + return _hashTask!.whenComplete(() { + _hashTask = null; + }); + } + Future syncRemote() { if (_syncTask != null) { return _syncTask!.future; diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index 74c3e7a8f7..9657173c3c 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -9,6 +9,8 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin { TextColumn get name => text()(); DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); IntColumn get backupSelection => intEnum()(); + BoolColumn get isIosSharedAlbum => + boolean().withDefault(const Constant(false))(); // Used for mark & sweep BoolColumn get marker_ => boolean().nullable()(); diff --git a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart index 5955742ec0..ff6226ba3f 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart @@ -14,6 +14,7 @@ typedef $$LocalAlbumEntityTableCreateCompanionBuilder required String name, i0.Value updatedAt, required i2.BackupSelection backupSelection, + i0.Value isIosSharedAlbum, i0.Value marker_, }); typedef $$LocalAlbumEntityTableUpdateCompanionBuilder @@ -22,6 +23,7 @@ typedef $$LocalAlbumEntityTableUpdateCompanionBuilder i0.Value name, i0.Value updatedAt, i0.Value backupSelection, + i0.Value isIosSharedAlbum, i0.Value marker_, }); @@ -48,6 +50,10 @@ class $$LocalAlbumEntityTableFilterComposer column: $table.backupSelection, builder: (column) => i0.ColumnWithTypeConverterFilters(column)); + i0.ColumnFilters get isIosSharedAlbum => $composableBuilder( + column: $table.isIosSharedAlbum, + builder: (column) => i0.ColumnFilters(column)); + i0.ColumnFilters get marker_ => $composableBuilder( column: $table.marker_, builder: (column) => i0.ColumnFilters(column)); } @@ -75,6 +81,10 @@ class $$LocalAlbumEntityTableOrderingComposer column: $table.backupSelection, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get isIosSharedAlbum => $composableBuilder( + column: $table.isIosSharedAlbum, + builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get marker_ => $composableBuilder( column: $table.marker_, builder: (column) => i0.ColumnOrderings(column)); } @@ -101,6 +111,9 @@ class $$LocalAlbumEntityTableAnnotationComposer get backupSelection => $composableBuilder( column: $table.backupSelection, builder: (column) => column); + i0.GeneratedColumn get isIosSharedAlbum => $composableBuilder( + column: $table.isIosSharedAlbum, builder: (column) => column); + i0.GeneratedColumn get marker_ => $composableBuilder(column: $table.marker_, builder: (column) => column); } @@ -139,6 +152,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager< i0.Value updatedAt = const i0.Value.absent(), i0.Value backupSelection = const i0.Value.absent(), + i0.Value isIosSharedAlbum = const i0.Value.absent(), i0.Value marker_ = const i0.Value.absent(), }) => i1.LocalAlbumEntityCompanion( @@ -146,6 +160,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager< name: name, updatedAt: updatedAt, backupSelection: backupSelection, + isIosSharedAlbum: isIosSharedAlbum, marker_: marker_, ), createCompanionCallback: ({ @@ -153,6 +168,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager< required String name, i0.Value updatedAt = const i0.Value.absent(), required i2.BackupSelection backupSelection, + i0.Value isIosSharedAlbum = const i0.Value.absent(), i0.Value marker_ = const i0.Value.absent(), }) => i1.LocalAlbumEntityCompanion.insert( @@ -160,6 +176,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager< name: name, updatedAt: updatedAt, backupSelection: backupSelection, + isIosSharedAlbum: isIosSharedAlbum, marker_: marker_, ), withReferenceMapper: (p0) => p0 @@ -218,6 +235,16 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity type: i0.DriftSqlType.int, requiredDuringInsert: true) .withConverter( i1.$LocalAlbumEntityTable.$converterbackupSelection); + static const i0.VerificationMeta _isIosSharedAlbumMeta = + const i0.VerificationMeta('isIosSharedAlbum'); + @override + late final i0.GeneratedColumn isIosSharedAlbum = + i0.GeneratedColumn('is_ios_shared_album', aliasedName, false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))'), + defaultValue: const i4.Constant(false)); static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta('marker_'); @override @@ -229,7 +256,7 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))')); @override List get $columns => - [id, name, updatedAt, backupSelection, marker_]; + [id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -256,6 +283,12 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } + if (data.containsKey('is_ios_shared_album')) { + context.handle( + _isIosSharedAlbumMeta, + isIosSharedAlbum.isAcceptableOrUnknown( + data['is_ios_shared_album']!, _isIosSharedAlbumMeta)); + } if (data.containsKey('marker')) { context.handle(_marker_Meta, marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta)); @@ -279,6 +312,8 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection .fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int, data['${effectivePrefix}backup_selection'])!), + isIosSharedAlbum: attachedDatabase.typeMapping.read( + i0.DriftSqlType.bool, data['${effectivePrefix}is_ios_shared_album'])!, marker_: attachedDatabase.typeMapping .read(i0.DriftSqlType.bool, data['${effectivePrefix}marker']), ); @@ -305,12 +340,14 @@ class LocalAlbumEntityData extends i0.DataClass final String name; final DateTime updatedAt; final i2.BackupSelection backupSelection; + final bool isIosSharedAlbum; final bool? marker_; const LocalAlbumEntityData( {required this.id, required this.name, required this.updatedAt, required this.backupSelection, + required this.isIosSharedAlbum, this.marker_}); @override Map toColumns(bool nullToAbsent) { @@ -323,6 +360,7 @@ class LocalAlbumEntityData extends i0.DataClass .$LocalAlbumEntityTable.$converterbackupSelection .toSql(backupSelection)); } + map['is_ios_shared_album'] = i0.Variable(isIosSharedAlbum); if (!nullToAbsent || marker_ != null) { map['marker'] = i0.Variable(marker_); } @@ -338,6 +376,7 @@ class LocalAlbumEntityData extends i0.DataClass updatedAt: serializer.fromJson(json['updatedAt']), backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection .fromJson(serializer.fromJson(json['backupSelection'])), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), marker_: serializer.fromJson(json['marker_']), ); } @@ -351,6 +390,7 @@ class LocalAlbumEntityData extends i0.DataClass 'backupSelection': serializer.toJson(i1 .$LocalAlbumEntityTable.$converterbackupSelection .toJson(backupSelection)), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), 'marker_': serializer.toJson(marker_), }; } @@ -360,12 +400,14 @@ class LocalAlbumEntityData extends i0.DataClass String? name, DateTime? updatedAt, i2.BackupSelection? backupSelection, + bool? isIosSharedAlbum, i0.Value marker_ = const i0.Value.absent()}) => i1.LocalAlbumEntityData( id: id ?? this.id, name: name ?? this.name, updatedAt: updatedAt ?? this.updatedAt, backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, marker_: marker_.present ? marker_.value : this.marker_, ); LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) { @@ -376,6 +418,9 @@ class LocalAlbumEntityData extends i0.DataClass backupSelection: data.backupSelection.present ? data.backupSelection.value : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, marker_: data.marker_.present ? data.marker_.value : this.marker_, ); } @@ -387,14 +432,15 @@ class LocalAlbumEntityData extends i0.DataClass ..write('name: $name, ') ..write('updatedAt: $updatedAt, ') ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') ..write('marker_: $marker_') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(id, name, updatedAt, backupSelection, marker_); + int get hashCode => Object.hash( + id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_); @override bool operator ==(Object other) => identical(this, other) || @@ -403,6 +449,7 @@ class LocalAlbumEntityData extends i0.DataClass other.name == this.name && other.updatedAt == this.updatedAt && other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && other.marker_ == this.marker_); } @@ -412,12 +459,14 @@ class LocalAlbumEntityCompanion final i0.Value name; final i0.Value updatedAt; final i0.Value backupSelection; + final i0.Value isIosSharedAlbum; final i0.Value marker_; const LocalAlbumEntityCompanion({ this.id = const i0.Value.absent(), this.name = const i0.Value.absent(), this.updatedAt = const i0.Value.absent(), this.backupSelection = const i0.Value.absent(), + this.isIosSharedAlbum = const i0.Value.absent(), this.marker_ = const i0.Value.absent(), }); LocalAlbumEntityCompanion.insert({ @@ -425,6 +474,7 @@ class LocalAlbumEntityCompanion required String name, this.updatedAt = const i0.Value.absent(), required i2.BackupSelection backupSelection, + this.isIosSharedAlbum = const i0.Value.absent(), this.marker_ = const i0.Value.absent(), }) : id = i0.Value(id), name = i0.Value(name), @@ -434,6 +484,7 @@ class LocalAlbumEntityCompanion i0.Expression? name, i0.Expression? updatedAt, i0.Expression? backupSelection, + i0.Expression? isIosSharedAlbum, i0.Expression? marker_, }) { return i0.RawValuesInsertable({ @@ -441,6 +492,7 @@ class LocalAlbumEntityCompanion if (name != null) 'name': name, if (updatedAt != null) 'updated_at': updatedAt, if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, if (marker_ != null) 'marker': marker_, }); } @@ -450,12 +502,14 @@ class LocalAlbumEntityCompanion i0.Value? name, i0.Value? updatedAt, i0.Value? backupSelection, + i0.Value? isIosSharedAlbum, i0.Value? marker_}) { return i1.LocalAlbumEntityCompanion( id: id ?? this.id, name: name ?? this.name, updatedAt: updatedAt ?? this.updatedAt, backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, marker_: marker_ ?? this.marker_, ); } @@ -477,6 +531,9 @@ class LocalAlbumEntityCompanion .$LocalAlbumEntityTable.$converterbackupSelection .toSql(backupSelection.value)); } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = i0.Variable(isIosSharedAlbum.value); + } if (marker_.present) { map['marker'] = i0.Variable(marker_.value); } @@ -490,6 +547,7 @@ class LocalAlbumEntityCompanion ..write('name: $name, ') ..write('updatedAt: $updatedAt, ') ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') ..write('marker_: $marker_') ..write(')')) .toString(); diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 650b7a1aab..5100b7a192 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -98,12 +98,24 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository name: localAlbum.name, updatedAt: Value(localAlbum.updatedAt), backupSelection: localAlbum.backupSelection, + isIosSharedAlbum: Value(localAlbum.isIosSharedAlbum), ); return _db.transaction(() async { await _db.localAlbumEntity .insertOne(companion, onConflict: DoUpdate((_) => companion)); - await _addAssets(localAlbum.id, toUpsert); + if (toUpsert.isNotEmpty) { + await _upsertAssets(toUpsert); + await _db.localAlbumAssetEntity.insertAll( + toUpsert.map( + (a) => LocalAlbumAssetEntityCompanion.insert( + assetId: a.id, + albumId: localAlbum.id, + ), + ), + mode: InsertMode.insertOrIgnore, + ); + } await _removeAssets(localAlbum.id, toDelete); }); } @@ -122,6 +134,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository name: album.name, updatedAt: Value(album.updatedAt), backupSelection: album.backupSelection, + isIosSharedAlbum: Value(album.isIosSharedAlbum), marker_: const Value(null), ); @@ -226,21 +239,52 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository }); } - Future _addAssets(String albumId, Iterable assets) { - if (assets.isEmpty) { + @override + Future> getAssetsToHash(String albumId) { + final query = _db.localAlbumAssetEntity.select().join( + [ + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ], + ) + ..where( + _db.localAlbumAssetEntity.albumId.equals(albumId) & + _db.localAssetEntity.checksum.isNull(), + ) + ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]); + + return query + .map((row) => row.readTable(_db.localAssetEntity).toDto()) + .get(); + } + + Future _upsertAssets(Iterable localAssets) { + if (localAssets.isEmpty) { return Future.value(); } - return transaction(() async { - await _upsertAssets(assets); - await _db.localAlbumAssetEntity.insertAll( - assets.map( - (a) => LocalAlbumAssetEntityCompanion.insert( - assetId: a.id, - albumId: albumId, + + return _db.batch((batch) async { + for (final asset in localAssets) { + final companion = LocalAssetEntityCompanion.insert( + name: asset.name, + type: asset.type, + createdAt: Value(asset.createdAt), + updatedAt: Value(asset.updatedAt), + durationInSeconds: Value.absentIfNull(asset.durationInSeconds), + id: asset.id, + checksum: const Value(null), + ); + batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>( + _db.localAssetEntity, + companion, + onConflict: DoUpdate( + (_) => companion, + where: (old) => old.updatedAt.isNotValue(asset.updatedAt), ), - ), - mode: InsertMode.insertOrIgnore, - ); + ); + } }); } @@ -301,40 +345,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository return query.map((row) => row.read(assetId)!).get(); } - Future _upsertAssets(Iterable localAssets) { - if (localAssets.isEmpty) { - return Future.value(); - } - - return _db.batch((batch) async { - batch.insertAllOnConflictUpdate( - _db.localAssetEntity, - localAssets.map( - (a) => LocalAssetEntityCompanion.insert( - name: a.name, - type: a.type, - createdAt: Value(a.createdAt), - updatedAt: Value(a.updatedAt), - durationInSeconds: Value.absentIfNull(a.durationInSeconds), - id: a.id, - checksum: Value.absentIfNull(a.checksum), - ), - ), - ); - }); - } - Future _deleteAssets(Iterable ids) { if (ids.isEmpty) { return Future.value(); } - return _db.batch( - (batch) => batch.deleteWhere( - _db.localAssetEntity, - (f) => f.id.isIn(ids), - ), - ); + return _db.batch((batch) { + batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids)); + }); } } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart new file mode 100644 index 0000000000..350a8dcd32 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftLocalAssetRepository extends DriftDatabaseRepository + implements ILocalAssetRepository { + final Drift _db; + const DriftLocalAssetRepository(this._db) : super(_db); + + @override + Future updateHashes(Iterable hashes) { + if (hashes.isEmpty) { + return Future.value(); + } + + return _db.batch((batch) async { + for (final asset in hashes) { + batch.update( + _db.localAssetEntity, + LocalAssetEntityCompanion(checksum: Value(asset.checksum)), + where: (e) => e.id.equals(asset.id), + ); + } + }); + } +} diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart new file mode 100644 index 0000000000..57dfc42135 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:immich_mobile/domain/interfaces/storage.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:logging/logging.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class StorageRepository implements IStorageRepository { + final _log = Logger('StorageRepository'); + + @override + Future getFileForAsset(LocalAsset asset) async { + File? file; + try { + final entity = await AssetEntity.fromId(asset.id); + file = await entity?.originFile; + if (file == null) { + _log.warning( + "Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + ); + } + } catch (error, stackTrace) { + _log.warning( + "Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + error, + stackTrace, + ); + } + return file; + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index ab6ea71310..ae87c0f3ae 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -18,8 +18,8 @@ import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index c4e4c467d4..ffcef67962 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -498,4 +498,35 @@ class NativeSyncApi { return (pigeonVar_replyList[0] as List?)!.cast(); } } + + Future> hashPaths(List paths) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([paths]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } } diff --git a/mobile/lib/presentation/pages/dev/dev_logger.dart b/mobile/lib/presentation/pages/dev/dev_logger.dart index 6d179241a4..ab9849f87c 100644 --- a/mobile/lib/presentation/pages/dev/dev_logger.dart +++ b/mobile/lib/presentation/pages/dev/dev_logger.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; @@ -15,7 +16,6 @@ abstract final class DLog { static Stream> watchLog() { final db = Isar.getInstance(); if (db == null) { - debugPrint('Isar is not initialized'); return const Stream.empty(); } @@ -30,7 +30,6 @@ abstract final class DLog { static void clearLog() { final db = Isar.getInstance(); if (db == null) { - debugPrint('Isar is not initialized'); return; } @@ -40,7 +39,9 @@ abstract final class DLog { } static void log(String message, [Object? error, StackTrace? stackTrace]) { - debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message'); + if (!Platform.environment.containsKey('FLUTTER_TEST')) { + debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message'); + } if (error != null) { debugPrint('Error: $error'); } @@ -50,7 +51,6 @@ abstract final class DLog { final isar = Isar.getInstance(); if (isar == null) { - debugPrint('Isar is not initialized'); return; } 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 3ff0b12b95..edbbd23796 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -26,6 +26,11 @@ final _features = [ icon: Icons.photo_library_rounded, onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true), ), + _Feature( + name: 'Hash Local Assets', + icon: Icons.numbers_outlined, + onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(), + ), _Feature( name: 'Sync Remote', icon: Icons.refresh_rounded, diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index 5debeff31d..c074e524bf 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; @@ -94,9 +93,8 @@ class LocalMediaSummaryPage extends StatelessWidget { ), FutureBuilder( future: albumsFuture, - initialData: [], builder: (_, snap) { - final albums = snap.data!; + final albums = snap.data ?? []; if (albums.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart new file mode 100644 index 0000000000..d714571473 --- /dev/null +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -0,0 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final localAssetRepository = Provider( + (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/providers/infrastructure/storage.provider.dart b/mobile/lib/providers/infrastructure/storage.provider.dart new file mode 100644 index 0000000000..d8ac79f1c1 --- /dev/null +++ b/mobile/lib/providers/infrastructure/storage.provider.dart @@ -0,0 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/storage.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; + +final storageRepositoryProvider = Provider( + (ref) => StorageRepository(), +); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 96e470eba2..359af63232 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -1,13 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; final syncStreamServiceProvider = Provider( @@ -33,3 +36,12 @@ final localSyncServiceProvider = Provider( storeService: ref.watch(storeServiceProvider), ), ); + +final hashServiceProvider = Provider( + (ref) => HashService( + localAlbumRepository: ref.watch(localAlbumRepository), + localAssetRepository: ref.watch(localAssetRepository), + storageRepository: ref.watch(storageRepositoryProvider), + nativeSyncApi: ref.watch(nativeSyncApiProvider), + ), +); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 4519c6d803..a31e441b1f 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -1,10 +1,13 @@ // ignore_for_file: avoid-unsafe-collection-methods import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -13,14 +16,16 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 11; +const int targetVersion = 12; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, targetVersion); @@ -45,7 +50,15 @@ Future migrateDatabaseIfNeeded(Isar db) async { await _migrateDeviceAsset(db); } - final shouldTruncate = version < 8 && version < targetVersion; + if (version < 12 && (!kReleaseMode)) { + final backgroundSync = BackgroundSyncManager(); + await backgroundSync.syncLocal(); + final drift = Drift(); + await _migrateDeviceAssetToSqlite(db, drift); + await drift.close(); + } + + final shouldTruncate = version < 8 || version < targetVersion; if (shouldTruncate) { await _migrateTo(db, targetVersion); } @@ -154,6 +167,28 @@ Future _migrateDeviceAsset(Isar db) async { }); } +Future _migrateDeviceAssetToSqlite(Isar db, Drift drift) async { + final isarDeviceAssets = + await db.deviceAssetEntitys.where().sortByAssetId().findAll(); + await drift.batch((batch) { + for (final deviceAsset in isarDeviceAssets) { + final companion = LocalAssetEntityCompanion( + updatedAt: Value(deviceAsset.modifiedTime), + id: Value(deviceAsset.assetId), + checksum: Value(base64.encode(deviceAsset.hash)), + ); + batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>( + drift.localAssetEntity, + companion, + onConflict: DoUpdate( + (_) => companion, + where: (old) => old.updatedAt.equals(deviceAsset.modifiedTime), + ), + ); + } + }); +} + class _DeviceAsset { final String assetId; final List? hash; diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index c3bfe5a978..8e14d232f8 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -12,6 +12,7 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'tags', TagsResponse().toJson()); addDefault(value, 'sharedLinks', SharedLinksResponse().toJson()); addDefault(value, 'cast', CastResponse().toJson()); + addDefault(value, 'albums', {'defaultAssetOrder': 'desc'}); } break; case 'ServerConfigDto': diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4ff55e5db8..74597b43bc 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -289,6 +289,8 @@ Class | Method | HTTP request | Description - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) - [AlbumUserRole](doc//AlbumUserRole.md) + - [AlbumsResponse](doc//AlbumsResponse.md) + - [AlbumsUpdate](doc//AlbumsUpdate.md) - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md) - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 87d14248eb..7b49661844 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -78,6 +78,8 @@ part 'model/album_user_add_dto.dart'; part 'model/album_user_create_dto.dart'; part 'model/album_user_response_dto.dart'; part 'model/album_user_role.dart'; +part 'model/albums_response.dart'; +part 'model/albums_update.dart'; part 'model/all_job_status_response_dto.dart'; part 'model/asset_bulk_delete_dto.dart'; part 'model/asset_bulk_update_dto.dart'; diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 33914d5b47..042bc70401 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -20,28 +20,39 @@ class TimelineApi { /// Parameters: /// /// * [String] timeBucket (required): + /// Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024) /// /// * [String] albumId: + /// Filter assets belonging to a specific album /// /// * [bool] isFavorite: + /// Filter by favorite status (true for favorites only, false for non-favorites only) /// /// * [bool] isTrashed: + /// Filter by trash status (true for trashed assets only, false for non-trashed only) /// /// * [String] key: /// /// * [AssetOrder] order: + /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// /// * [String] personId: + /// Filter assets containing a specific person (face recognition) /// /// * [String] tagId: + /// Filter assets with a specific tag /// /// * [String] userId: + /// Filter assets by specific user ID /// /// * [AssetVisibility] visibility: + /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// /// * [bool] withPartners: + /// Include assets shared by partners /// /// * [bool] withStacked: + /// Include stacked assets in the response. When true, only primary assets from stacks are returned. Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -105,28 +116,39 @@ class TimelineApi { /// Parameters: /// /// * [String] timeBucket (required): + /// Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024) /// /// * [String] albumId: + /// Filter assets belonging to a specific album /// /// * [bool] isFavorite: + /// Filter by favorite status (true for favorites only, false for non-favorites only) /// /// * [bool] isTrashed: + /// Filter by trash status (true for trashed assets only, false for non-trashed only) /// /// * [String] key: /// /// * [AssetOrder] order: + /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// /// * [String] personId: + /// Filter assets containing a specific person (face recognition) /// /// * [String] tagId: + /// Filter assets with a specific tag /// /// * [String] userId: + /// Filter assets by specific user ID /// /// * [AssetVisibility] visibility: + /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// /// * [bool] withPartners: + /// Include assets shared by partners /// /// * [bool] withStacked: + /// Include stacked assets in the response. When true, only primary assets from stacks are returned. Future getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { @@ -146,26 +168,36 @@ class TimelineApi { /// Parameters: /// /// * [String] albumId: + /// Filter assets belonging to a specific album /// /// * [bool] isFavorite: + /// Filter by favorite status (true for favorites only, false for non-favorites only) /// /// * [bool] isTrashed: + /// Filter by trash status (true for trashed assets only, false for non-trashed only) /// /// * [String] key: /// /// * [AssetOrder] order: + /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// /// * [String] personId: + /// Filter assets containing a specific person (face recognition) /// /// * [String] tagId: + /// Filter assets with a specific tag /// /// * [String] userId: + /// Filter assets by specific user ID /// /// * [AssetVisibility] visibility: + /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// /// * [bool] withPartners: + /// Include assets shared by partners /// /// * [bool] withStacked: + /// Include stacked assets in the response. When true, only primary assets from stacks are returned. Future getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -228,26 +260,36 @@ class TimelineApi { /// Parameters: /// /// * [String] albumId: + /// Filter assets belonging to a specific album /// /// * [bool] isFavorite: + /// Filter by favorite status (true for favorites only, false for non-favorites only) /// /// * [bool] isTrashed: + /// Filter by trash status (true for trashed assets only, false for non-trashed only) /// /// * [String] key: /// /// * [AssetOrder] order: + /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// /// * [String] personId: + /// Filter assets containing a specific person (face recognition) /// /// * [String] tagId: + /// Filter assets with a specific tag /// /// * [String] userId: + /// Filter assets by specific user ID /// /// * [AssetVisibility] visibility: + /// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED) /// /// * [bool] withPartners: + /// Include assets shared by partners /// /// * [bool] withStacked: + /// Include stacked assets in the response. When true, only primary assets from stacks are returned. Future?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 46936fa88b..a96b895655 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -212,6 +212,10 @@ class ApiClient { return AlbumUserResponseDto.fromJson(value); case 'AlbumUserRole': return AlbumUserRoleTypeTransformer().decode(value); + case 'AlbumsResponse': + return AlbumsResponse.fromJson(value); + case 'AlbumsUpdate': + return AlbumsUpdate.fromJson(value); case 'AllJobStatusResponseDto': return AllJobStatusResponseDto.fromJson(value); case 'AssetBulkDeleteDto': diff --git a/mobile/openapi/lib/model/albums_response.dart b/mobile/openapi/lib/model/albums_response.dart new file mode 100644 index 0000000000..4f9a8eb8f2 --- /dev/null +++ b/mobile/openapi/lib/model/albums_response.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumsResponse { + /// Returns a new [AlbumsResponse] instance. + AlbumsResponse({ + this.defaultAssetOrder = AssetOrder.desc, + }); + + AssetOrder defaultAssetOrder; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumsResponse && + other.defaultAssetOrder == defaultAssetOrder; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (defaultAssetOrder.hashCode); + + @override + String toString() => 'AlbumsResponse[defaultAssetOrder=$defaultAssetOrder]'; + + Map toJson() { + final json = {}; + json[r'defaultAssetOrder'] = this.defaultAssetOrder; + return json; + } + + /// Returns a new [AlbumsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumsResponse? fromJson(dynamic value) { + upgradeDto(value, "AlbumsResponse"); + if (value is Map) { + final json = value.cast(); + + return AlbumsResponse( + defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumsResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AlbumsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumsResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumsResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'defaultAssetOrder', + }; +} + diff --git a/mobile/openapi/lib/model/albums_update.dart b/mobile/openapi/lib/model/albums_update.dart new file mode 100644 index 0000000000..d61b5c1398 --- /dev/null +++ b/mobile/openapi/lib/model/albums_update.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumsUpdate { + /// Returns a new [AlbumsUpdate] instance. + AlbumsUpdate({ + this.defaultAssetOrder, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetOrder? defaultAssetOrder; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumsUpdate && + other.defaultAssetOrder == defaultAssetOrder; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (defaultAssetOrder == null ? 0 : defaultAssetOrder!.hashCode); + + @override + String toString() => 'AlbumsUpdate[defaultAssetOrder=$defaultAssetOrder]'; + + Map toJson() { + final json = {}; + if (this.defaultAssetOrder != null) { + json[r'defaultAssetOrder'] = this.defaultAssetOrder; + } else { + // json[r'defaultAssetOrder'] = null; + } + return json; + } + + /// Returns a new [AlbumsUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumsUpdate? fromJson(dynamic value) { + upgradeDto(value, "AlbumsUpdate"); + if (value is Map) { + final json = value.cast(); + + return AlbumsUpdate( + defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumsUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AlbumsUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumsUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumsUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 3d85b779cc..e2f60937f8 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -65,8 +65,10 @@ class AssetResponseDto { /// ExifResponseDto? exifInfo; + /// The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. DateTime fileCreatedAt; + /// The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. DateTime fileModifiedAt; bool hasMetadata; @@ -86,6 +88,7 @@ class AssetResponseDto { String? livePhotoVideoId; + /// The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months. DateTime localDateTime; String originalFileName; @@ -131,6 +134,7 @@ class AssetResponseDto { List unassignedFaces; + /// The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. DateTime updatedAt; AssetVisibility visibility; diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 3f1406c019..886b353f68 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -16,12 +16,13 @@ class TimeBucketAssetResponseDto { this.city = const [], this.country = const [], this.duration = const [], + this.fileCreatedAt = const [], this.id = const [], this.isFavorite = const [], this.isImage = const [], this.isTrashed = const [], this.livePhotoVideoId = const [], - this.localDateTime = const [], + this.localOffsetHours = const [], this.ownerId = const [], this.projectionType = const [], this.ratio = const [], @@ -30,35 +31,52 @@ class TimeBucketAssetResponseDto { this.visibility = const [], }); + /// Array of city names extracted from EXIF GPS data List city; + /// Array of country names extracted from EXIF GPS data List country; + /// Array of video durations in HH:MM:SS format (null for images) List duration; + /// Array of file creation timestamps in UTC (ISO 8601 format, without timezone) + List fileCreatedAt; + + /// Array of asset IDs in the time bucket List id; + /// Array indicating whether each asset is favorited List isFavorite; + /// Array indicating whether each asset is an image (false for videos) List isImage; + /// Array indicating whether each asset is in the trash List isTrashed; + /// Array of live photo video asset IDs (null for non-live photos) List livePhotoVideoId; - List localDateTime; + /// Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. + List localOffsetHours; + /// Array of owner IDs for each asset List ownerId; + /// Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\") List projectionType; + /// Array of aspect ratios (width/height) for each asset List ratio; - /// (stack ID, stack asset count) tuple + /// Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets) List?> stack; + /// Array of BlurHash strings for generating asset previews (base64 encoded) List thumbhash; + /// Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED) List visibility; @override @@ -66,12 +84,13 @@ class TimeBucketAssetResponseDto { _deepEquality.equals(other.city, city) && _deepEquality.equals(other.country, country) && _deepEquality.equals(other.duration, duration) && + _deepEquality.equals(other.fileCreatedAt, fileCreatedAt) && _deepEquality.equals(other.id, id) && _deepEquality.equals(other.isFavorite, isFavorite) && _deepEquality.equals(other.isImage, isImage) && _deepEquality.equals(other.isTrashed, isTrashed) && _deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) && - _deepEquality.equals(other.localDateTime, localDateTime) && + _deepEquality.equals(other.localOffsetHours, localOffsetHours) && _deepEquality.equals(other.ownerId, ownerId) && _deepEquality.equals(other.projectionType, projectionType) && _deepEquality.equals(other.ratio, ratio) && @@ -85,12 +104,13 @@ class TimeBucketAssetResponseDto { (city.hashCode) + (country.hashCode) + (duration.hashCode) + + (fileCreatedAt.hashCode) + (id.hashCode) + (isFavorite.hashCode) + (isImage.hashCode) + (isTrashed.hashCode) + (livePhotoVideoId.hashCode) + - (localDateTime.hashCode) + + (localOffsetHours.hashCode) + (ownerId.hashCode) + (projectionType.hashCode) + (ratio.hashCode) + @@ -99,19 +119,20 @@ class TimeBucketAssetResponseDto { (visibility.hashCode); @override - String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]'; + String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]'; Map toJson() { final json = {}; json[r'city'] = this.city; json[r'country'] = this.country; json[r'duration'] = this.duration; + json[r'fileCreatedAt'] = this.fileCreatedAt; json[r'id'] = this.id; json[r'isFavorite'] = this.isFavorite; json[r'isImage'] = this.isImage; json[r'isTrashed'] = this.isTrashed; json[r'livePhotoVideoId'] = this.livePhotoVideoId; - json[r'localDateTime'] = this.localDateTime; + json[r'localOffsetHours'] = this.localOffsetHours; json[r'ownerId'] = this.ownerId; json[r'projectionType'] = this.projectionType; json[r'ratio'] = this.ratio; @@ -139,6 +160,9 @@ class TimeBucketAssetResponseDto { duration: json[r'duration'] is Iterable ? (json[r'duration'] as Iterable).cast().toList(growable: false) : const [], + fileCreatedAt: json[r'fileCreatedAt'] is Iterable + ? (json[r'fileCreatedAt'] as Iterable).cast().toList(growable: false) + : const [], id: json[r'id'] is Iterable ? (json[r'id'] as Iterable).cast().toList(growable: false) : const [], @@ -154,8 +178,8 @@ class TimeBucketAssetResponseDto { livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable ? (json[r'livePhotoVideoId'] as Iterable).cast().toList(growable: false) : const [], - localDateTime: json[r'localDateTime'] is Iterable - ? (json[r'localDateTime'] as Iterable).cast().toList(growable: false) + localOffsetHours: json[r'localOffsetHours'] is Iterable + ? (json[r'localOffsetHours'] as Iterable).cast().toList(growable: false) : const [], ownerId: json[r'ownerId'] is Iterable ? (json[r'ownerId'] as Iterable).cast().toList(growable: false) @@ -225,12 +249,13 @@ class TimeBucketAssetResponseDto { 'city', 'country', 'duration', + 'fileCreatedAt', 'id', 'isFavorite', 'isImage', 'isTrashed', 'livePhotoVideoId', - 'localDateTime', + 'localOffsetHours', 'ownerId', 'projectionType', 'ratio', diff --git a/mobile/openapi/lib/model/time_buckets_response_dto.dart b/mobile/openapi/lib/model/time_buckets_response_dto.dart index 8c9f8dab61..11faa815e2 100644 --- a/mobile/openapi/lib/model/time_buckets_response_dto.dart +++ b/mobile/openapi/lib/model/time_buckets_response_dto.dart @@ -17,8 +17,10 @@ class TimeBucketsResponseDto { required this.timeBucket, }); + /// Number of assets in this time bucket int count; + /// Time bucket identifier in YYYY-MM-DD format representing the start of the time period String timeBucket; @override diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index c729e0d80f..7a6e0252af 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserPreferencesResponseDto { /// Returns a new [UserPreferencesResponseDto] instance. UserPreferencesResponseDto({ + required this.albums, required this.cast, required this.download, required this.emailNotifications, @@ -25,6 +26,8 @@ class UserPreferencesResponseDto { required this.tags, }); + AlbumsResponse albums; + CastResponse cast; DownloadResponse download; @@ -47,6 +50,7 @@ class UserPreferencesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && + other.albums == albums && other.cast == cast && other.download == download && other.emailNotifications == emailNotifications && @@ -61,6 +65,7 @@ class UserPreferencesResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albums.hashCode) + (cast.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + @@ -73,10 +78,11 @@ class UserPreferencesResponseDto { (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; + String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; + json[r'albums'] = this.albums; json[r'cast'] = this.cast; json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; @@ -99,6 +105,7 @@ class UserPreferencesResponseDto { final json = value.cast(); return UserPreferencesResponseDto( + albums: AlbumsResponse.fromJson(json[r'albums'])!, cast: CastResponse.fromJson(json[r'cast'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, @@ -156,6 +163,7 @@ class UserPreferencesResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'albums', 'cast', 'download', 'emailNotifications', diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 73e3cac9ff..3b9b178b55 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserPreferencesUpdateDto { /// Returns a new [UserPreferencesUpdateDto] instance. UserPreferencesUpdateDto({ + this.albums, this.avatar, this.cast, this.download, @@ -26,6 +27,14 @@ class UserPreferencesUpdateDto { this.tags, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AlbumsUpdate? albums; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -116,6 +125,7 @@ class UserPreferencesUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && + other.albums == albums && other.avatar == avatar && other.cast == cast && other.download == download && @@ -131,6 +141,7 @@ class UserPreferencesUpdateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albums == null ? 0 : albums!.hashCode) + (avatar == null ? 0 : avatar!.hashCode) + (cast == null ? 0 : cast!.hashCode) + (download == null ? 0 : download!.hashCode) + @@ -144,10 +155,15 @@ class UserPreferencesUpdateDto { (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; + String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; + if (this.albums != null) { + json[r'albums'] = this.albums; + } else { + // json[r'albums'] = null; + } if (this.avatar != null) { json[r'avatar'] = this.avatar; } else { @@ -215,6 +231,7 @@ class UserPreferencesUpdateDto { final json = value.cast(); return UserPreferencesUpdateDto( + albums: AlbumsUpdate.fromJson(json[r'albums']), avatar: AvatarUpdate.fromJson(json[r'avatar']), cast: CastUpdate.fromJson(json[r'cast']), download: DownloadUpdate.fromJson(json[r'download']), diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index b8a7500d6e..9bcb816a64 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -86,4 +86,7 @@ abstract class NativeSyncApi { @TaskQueue(type: TaskQueueType.serialBackgroundThread) List getAssetsForAlbum(String albumId, {int? updatedTimeCond}); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List hashPaths(List paths); } diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 97a3f30294..f4c5a32a4b 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreService extends Mock implements StoreService {} @@ -8,3 +9,5 @@ class MockStoreService extends Mock implements StoreService {} class MockUserService extends Mock implements UserService {} class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} + +class MockNativeSyncApi extends Mock implements NativeSyncApi {} diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart index 2da41cd704..1401f5d2a0 100644 --- a/mobile/test/domain/services/hash_service_test.dart +++ b/mobile/test/domain/services/hash_service_test.dart @@ -1,425 +1,292 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:math'; +import 'dart:typed_data'; -import 'package:collection/collection.dart'; -import 'package:file/memory.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; -import 'package:immich_mobile/domain/models/device_asset.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/services/hash.service.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/domain/services/hash.service.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:photo_manager/photo_manager.dart'; +import '../../fixtures/album.stub.dart'; import '../../fixtures/asset.stub.dart'; import '../../infrastructure/repository.mock.dart'; -import '../../service.mocks.dart'; +import '../service.mock.dart'; -class MockAsset extends Mock implements Asset {} - -class MockAssetEntity extends Mock implements AssetEntity {} +class MockFile extends Mock implements File {} void main() { late HashService sut; - late BackgroundService mockBackgroundService; - late IDeviceAssetRepository mockDeviceAssetRepository; + late MockLocalAlbumRepository mockAlbumRepo; + late MockLocalAssetRepository mockAssetRepo; + late MockStorageRepository mockStorageRepo; + late MockNativeSyncApi mockNativeApi; setUp(() { - mockBackgroundService = MockBackgroundService(); - mockDeviceAssetRepository = MockDeviceAssetRepository(); + mockAlbumRepo = MockLocalAlbumRepository(); + mockAssetRepo = MockLocalAssetRepository(); + mockStorageRepo = MockStorageRepository(); + mockNativeApi = MockNativeSyncApi(); sut = HashService( - deviceAssetRepository: mockDeviceAssetRepository, - backgroundService: mockBackgroundService, + localAlbumRepository: mockAlbumRepo, + localAssetRepository: mockAssetRepo, + storageRepository: mockStorageRepo, + nativeSyncApi: mockNativeApi, ); - when(() => mockDeviceAssetRepository.transaction(any())) - .thenAnswer((_) async { - final capturedCallback = verify( - () => mockDeviceAssetRepository.transaction(captureAny()), - ).captured; - // Invoke the transaction callback - await (capturedCallback.firstOrNull as Future Function()?)?.call(); - }); - when(() => mockDeviceAssetRepository.updateAll(any())) - .thenAnswer((_) async => true); - when(() => mockDeviceAssetRepository.deleteIds(any())) - .thenAnswer((_) async => true); + registerFallbackValue(LocalAlbumStub.recent); + registerFallbackValue(LocalAssetStub.image1); + + when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); }); - group("HashService: No DeviceAsset entry", () { - test("hash successfully", () async { - final (mockAsset, file, deviceAsset, hash) = - await _createAssetMock(AssetStub.image1); - - when(() => mockBackgroundService.digestFiles([file.path])) - .thenAnswer((_) async => [hash]); - // No DB entries for this asset - when( - () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), - ).thenAnswer((_) async => []); - - final result = await sut.hashAssets([mockAsset]); - - // Verify we stored the new hash in DB - when(() => mockDeviceAssetRepository.transaction(any())) - .thenAnswer((_) async { - final capturedCallback = verify( - () => mockDeviceAssetRepository.transaction(captureAny()), - ).captured; - // Invoke the transaction callback - await (capturedCallback.firstOrNull as Future Function()?) - ?.call(); - verify( - () => mockDeviceAssetRepository.updateAll([ - deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt), - ]), - ).called(1); - verify(() => mockDeviceAssetRepository.deleteIds([])).called(1); - }); - expect( - result, - [AssetStub.image1.copyWith(checksum: base64.encode(hash))], + group('HashService hashAssets', () { + test('processes albums in correct order', () async { + final album1 = LocalAlbumStub.recent + .copyWith(id: "1", backupSelection: BackupSelection.none); + final album2 = LocalAlbumStub.recent + .copyWith(id: "2", backupSelection: BackupSelection.excluded); + final album3 = LocalAlbumStub.recent + .copyWith(id: "3", backupSelection: BackupSelection.selected); + final album4 = LocalAlbumStub.recent.copyWith( + id: "4", + backupSelection: BackupSelection.selected, + isIosSharedAlbum: true, ); - }); - }); - group("HashService: Has DeviceAsset entry", () { - test("when the asset is not modified", () async { - final hash = utf8.encode("image1-hash"); + when(() => mockAlbumRepo.getAll()) + .thenAnswer((_) async => [album1, album2, album4, album3]); + when(() => mockAlbumRepo.getAssetsToHash(any())) + .thenAnswer((_) async => []); - when( - () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), - ).thenAnswer( - (_) async => [ - DeviceAsset( - assetId: AssetStub.image1.localId!, - hash: hash, - modifiedTime: AssetStub.image1.fileModifiedAt, - ), - ], - ); - final result = await sut.hashAssets([AssetStub.image1]); + await sut.hashAssets(); - verifyNever(() => mockBackgroundService.digestFiles(any())); - verifyNever(() => mockBackgroundService.digestFile(any())); - verifyNever(() => mockDeviceAssetRepository.updateAll(any())); - verifyNever(() => mockDeviceAssetRepository.deleteIds(any())); - - expect(result, [ - AssetStub.image1.copyWith(checksum: base64.encode(hash)), + verifyInOrder([ + () => mockAlbumRepo.getAll(), + () => mockAlbumRepo.getAssetsToHash(album3.id), + () => mockAlbumRepo.getAssetsToHash(album4.id), + () => mockAlbumRepo.getAssetsToHash(album1.id), + () => mockAlbumRepo.getAssetsToHash(album2.id), ]); }); - test("hashed successful when asset is modified", () async { - final (mockAsset, file, deviceAsset, hash) = - await _createAssetMock(AssetStub.image1); + test('skips albums with no assets to hash', () async { + when(() => mockAlbumRepo.getAll()).thenAnswer( + (_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)], + ); + when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id)) + .thenAnswer((_) async => []); - when(() => mockBackgroundService.digestFiles([file.path])) - .thenAnswer((_) async => [hash]); - when( - () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), - ).thenAnswer((_) async => [deviceAsset]); + await sut.hashAssets(); - final result = await sut.hashAssets([mockAsset]); - - when(() => mockDeviceAssetRepository.transaction(any())) - .thenAnswer((_) async { - final capturedCallback = verify( - () => mockDeviceAssetRepository.transaction(captureAny()), - ).captured; - // Invoke the transaction callback - await (capturedCallback.firstOrNull as Future Function()?) - ?.call(); - verify( - () => mockDeviceAssetRepository.updateAll([ - deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt), - ]), - ).called(1); - verify(() => mockDeviceAssetRepository.deleteIds([])).called(1); - }); - - verify(() => mockBackgroundService.digestFiles([file.path])).called(1); - - expect(result, [ - AssetStub.image1.copyWith(checksum: base64.encode(hash)), - ]); + verifyNever(() => mockStorageRepo.getFileForAsset(any())); + verifyNever(() => mockNativeApi.hashPaths(any())); }); }); - group("HashService: Cleanup", () { - late Asset mockAsset; - late Uint8List hash; - late DeviceAsset deviceAsset; - late File file; + group('HashService _hashAssets', () { + test('skips assets without files', () async { + final album = LocalAlbumStub.recent; + final asset = LocalAssetStub.image1; + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset]); + when(() => mockStorageRepo.getFileForAsset(asset)) + .thenAnswer((_) async => null); - setUp(() async { - (mockAsset, file, deviceAsset, hash) = - await _createAssetMock(AssetStub.image1); + await sut.hashAssets(); - when(() => mockBackgroundService.digestFiles([file.path])) - .thenAnswer((_) async => [hash]); - when( - () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), - ).thenAnswer((_) async => [deviceAsset]); + verifyNever(() => mockNativeApi.hashPaths(any())); }); - test("cleanups DeviceAsset when local file cannot be obtained", () async { - when(() => mockAsset.local).thenThrow(Exception("File not found")); - final result = await sut.hashAssets([mockAsset]); + test('processes assets when available', () async { + final album = LocalAlbumStub.recent; + final asset = LocalAssetStub.image1; + final mockFile = MockFile(); + final hash = Uint8List.fromList(List.generate(20, (i) => i)); - verifyNever(() => mockBackgroundService.digestFiles(any())); - verifyNever(() => mockBackgroundService.digestFile(any())); - verifyNever(() => mockDeviceAssetRepository.updateAll(any())); - verify( - () => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]), - ).called(1); + when(() => mockFile.length()).thenAnswer((_) async => 1000); + when(() => mockFile.path).thenReturn('image-path'); - expect(result, isEmpty); - }); - - test("cleanups DeviceAsset when hashing failed", () async { - when(() => mockDeviceAssetRepository.transaction(any())) - .thenAnswer((_) async { - final capturedCallback = verify( - () => mockDeviceAssetRepository.transaction(captureAny()), - ).captured; - // Invoke the transaction callback - await (capturedCallback.firstOrNull as Future Function()?) - ?.call(); - - // Verify the callback inside the transaction because, doing it outside results - // in a small delay before the callback is invoked, resulting in other LOCs getting executed - // resulting in an incorrect state - // - // i.e, consider the following piece of code - // await _deviceAssetRepository.transaction(() async { - // await _deviceAssetRepository.updateAll(toBeAdded); - // await _deviceAssetRepository.deleteIds(toBeDeleted); - // }); - // toBeDeleted.clear(); - // since the transaction method is mocked, the callback is not invoked until it is captured - // and executed manually in the next event loop. However, the toBeDeleted.clear() is executed - // immediately once the transaction stub is executed, resulting in the deleteIds method being - // called with an empty list. - // - // To avoid this, we capture the callback and execute it within the transaction stub itself - // and verify the results inside the transaction stub - verify(() => mockDeviceAssetRepository.updateAll([])).called(1); - verify( - () => - mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]), - ).called(1); - }); - - when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer( - // Invalid hash, length != 20 - (_) async => [Uint8List.fromList(hash.slice(2).toList())], + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset]); + when(() => mockStorageRepo.getFileForAsset(asset)) + .thenAnswer((_) async => mockFile); + when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer( + (_) async => [hash], ); - final result = await sut.hashAssets([mockAsset]); + await sut.hashAssets(); - verify(() => mockBackgroundService.digestFiles([file.path])).called(1); - expect(result, isEmpty); - }); - }); - - group("HashService: Batch processing", () { - test("processes assets in batches when size limit is reached", () async { - // Setup multiple assets with large file sizes - final (mock1, mock2, mock3) = await ( - _createAssetMock(AssetStub.image1), - _createAssetMock(AssetStub.image2), - _createAssetMock(AssetStub.image3), - ).wait; - - final (asset1, file1, deviceAsset1, hash1) = mock1; - final (asset2, file2, deviceAsset2, hash2) = mock2; - final (asset3, file3, deviceAsset3, hash3) = mock3; - - when(() => mockDeviceAssetRepository.getByIds(any())) - .thenAnswer((_) async => []); - - // Setup for multiple batch processing calls - when(() => mockBackgroundService.digestFiles([file1.path, file2.path])) - .thenAnswer((_) async => [hash1, hash2]); - when(() => mockBackgroundService.digestFiles([file3.path])) - .thenAnswer((_) async => [hash3]); - - final size = await file1.length() + await file2.length(); - - sut = HashService( - deviceAssetRepository: mockDeviceAssetRepository, - backgroundService: mockBackgroundService, - batchSizeLimit: size, - ); - final result = await sut.hashAssets([asset1, asset2, asset3]); - - // Verify multiple batch process calls - verify(() => mockBackgroundService.digestFiles([file1.path, file2.path])) - .called(1); - verify(() => mockBackgroundService.digestFiles([file3.path])).called(1); - - expect( - result, - [ - AssetStub.image1.copyWith(checksum: base64.encode(hash1)), - AssetStub.image2.copyWith(checksum: base64.encode(hash2)), - AssetStub.image3.copyWith(checksum: base64.encode(hash3)), - ], - ); + verify(() => mockNativeApi.hashPaths(['image-path'])).called(1); + final captured = verify(() => mockAssetRepo.updateHashes(captureAny())) + .captured + .first as List; + expect(captured.length, 1); + expect(captured[0].checksum, base64.encode(hash)); }); - test("processes assets in batches when file limit is reached", () async { - // Setup multiple assets with large file sizes - final (mock1, mock2, mock3) = await ( - _createAssetMock(AssetStub.image1), - _createAssetMock(AssetStub.image2), - _createAssetMock(AssetStub.image3), - ).wait; + test('handles failed hashes', () async { + final album = LocalAlbumStub.recent; + final asset = LocalAssetStub.image1; + final mockFile = MockFile(); + when(() => mockFile.length()).thenAnswer((_) async => 1000); + when(() => mockFile.path).thenReturn('image-path'); - final (asset1, file1, deviceAsset1, hash1) = mock1; - final (asset2, file2, deviceAsset2, hash2) = mock2; - final (asset3, file3, deviceAsset3, hash3) = mock3; + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset]); + when(() => mockStorageRepo.getFileForAsset(asset)) + .thenAnswer((_) async => mockFile); + when(() => mockNativeApi.hashPaths(['image-path'])) + .thenAnswer((_) async => [null]); + when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); - when(() => mockDeviceAssetRepository.getByIds(any())) - .thenAnswer((_) async => []); + await sut.hashAssets(); - when(() => mockBackgroundService.digestFiles([file1.path])) - .thenAnswer((_) async => [hash1]); - when(() => mockBackgroundService.digestFiles([file2.path])) - .thenAnswer((_) async => [hash2]); - when(() => mockBackgroundService.digestFiles([file3.path])) - .thenAnswer((_) async => [hash3]); + final captured = verify(() => mockAssetRepo.updateHashes(captureAny())) + .captured + .first as List; + expect(captured.length, 0); + }); - sut = HashService( - deviceAssetRepository: mockDeviceAssetRepository, - backgroundService: mockBackgroundService, + test('handles invalid hash length', () async { + final album = LocalAlbumStub.recent; + final asset = LocalAssetStub.image1; + final mockFile = MockFile(); + when(() => mockFile.length()).thenAnswer((_) async => 1000); + when(() => mockFile.path).thenReturn('image-path'); + + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset]); + when(() => mockStorageRepo.getFileForAsset(asset)) + .thenAnswer((_) async => mockFile); + + final invalidHash = Uint8List.fromList([1, 2, 3]); + when(() => mockNativeApi.hashPaths(['image-path'])) + .thenAnswer((_) async => [invalidHash]); + when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); + + await sut.hashAssets(); + + final captured = verify(() => mockAssetRepo.updateHashes(captureAny())) + .captured + .first as List; + expect(captured.length, 0); + }); + + test('batches by file count limit', () async { + final sut = HashService( + localAlbumRepository: mockAlbumRepo, + localAssetRepository: mockAssetRepo, + storageRepository: mockStorageRepo, + nativeSyncApi: mockNativeApi, batchFileLimit: 1, ); - final result = await sut.hashAssets([asset1, asset2, asset3]); - // Verify multiple batch process calls - verify(() => mockBackgroundService.digestFiles([file1.path])).called(1); - verify(() => mockBackgroundService.digestFiles([file2.path])).called(1); - verify(() => mockBackgroundService.digestFiles([file3.path])).called(1); + final album = LocalAlbumStub.recent; + final asset1 = LocalAssetStub.image1; + final asset2 = LocalAssetStub.image2; + final mockFile1 = MockFile(); + final mockFile2 = MockFile(); + when(() => mockFile1.length()).thenAnswer((_) async => 100); + when(() => mockFile1.path).thenReturn('path-1'); + when(() => mockFile2.length()).thenAnswer((_) async => 100); + when(() => mockFile2.path).thenReturn('path-2'); - expect( - result, - [ - AssetStub.image1.copyWith(checksum: base64.encode(hash1)), - AssetStub.image2.copyWith(checksum: base64.encode(hash2)), - AssetStub.image3.copyWith(checksum: base64.encode(hash3)), - ], - ); + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset1, asset2]); + when(() => mockStorageRepo.getFileForAsset(asset1)) + .thenAnswer((_) async => mockFile1); + when(() => mockStorageRepo.getFileForAsset(asset2)) + .thenAnswer((_) async => mockFile2); + + final hash = Uint8List.fromList(List.generate(20, (i) => i)); + when(() => mockNativeApi.hashPaths(any())) + .thenAnswer((_) async => [hash]); + when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); + + await sut.hashAssets(); + + verify(() => mockNativeApi.hashPaths(['path-1'])).called(1); + verify(() => mockNativeApi.hashPaths(['path-2'])).called(1); + verify(() => mockAssetRepo.updateHashes(any())).called(2); }); - test("HashService: Sort & Process different states", () async { - final (asset1, file1, deviceAsset1, hash1) = - await _createAssetMock(AssetStub.image1); // Will need rehashing - final (asset2, file2, deviceAsset2, hash2) = - await _createAssetMock(AssetStub.image2); // Will have matching hash - final (asset3, file3, deviceAsset3, hash3) = - await _createAssetMock(AssetStub.image3); // No DB entry - final asset4 = - AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed - - when(() => mockBackgroundService.digestFiles([file1.path, file3.path])) - .thenAnswer((_) async => [hash1, hash3]); - // DB entries are not sorted and a dummy entry added - when( - () => mockDeviceAssetRepository.getByIds([ - AssetStub.image1.localId!, - AssetStub.image2.localId!, - AssetStub.image3.localId!, - asset4.localId!, - ]), - ).thenAnswer( - (_) async => [ - // Same timestamp to reuse deviceAsset - deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt), - deviceAsset1, - deviceAsset3.copyWith(assetId: asset4.localId!), - ], + test('batches by size limit', () async { + final sut = HashService( + localAlbumRepository: mockAlbumRepo, + localAssetRepository: mockAssetRepo, + storageRepository: mockStorageRepo, + nativeSyncApi: mockNativeApi, + batchSizeLimit: 80, ); - final result = await sut.hashAssets([asset1, asset2, asset3, asset4]); + final album = LocalAlbumStub.recent; + final asset1 = LocalAssetStub.image1; + final asset2 = LocalAssetStub.image2; + final mockFile1 = MockFile(); + final mockFile2 = MockFile(); + when(() => mockFile1.length()).thenAnswer((_) async => 100); + when(() => mockFile1.path).thenReturn('path-1'); + when(() => mockFile2.length()).thenAnswer((_) async => 100); + when(() => mockFile2.path).thenReturn('path-2'); - // Verify correct processing of all assets - verify(() => mockBackgroundService.digestFiles([file1.path, file3.path])) - .called(1); - expect(result.length, 3); - expect(result, [ - AssetStub.image2.copyWith(checksum: base64.encode(hash2)), - AssetStub.image1.copyWith(checksum: base64.encode(hash1)), - AssetStub.image3.copyWith(checksum: base64.encode(hash3)), - ]); + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset1, asset2]); + when(() => mockStorageRepo.getFileForAsset(asset1)) + .thenAnswer((_) async => mockFile1); + when(() => mockStorageRepo.getFileForAsset(asset2)) + .thenAnswer((_) async => mockFile2); + + final hash = Uint8List.fromList(List.generate(20, (i) => i)); + when(() => mockNativeApi.hashPaths(any())) + .thenAnswer((_) async => [hash]); + when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); + + await sut.hashAssets(); + + verify(() => mockNativeApi.hashPaths(['path-1'])).called(1); + verify(() => mockNativeApi.hashPaths(['path-2'])).called(1); + verify(() => mockAssetRepo.updateHashes(any())).called(2); }); - group("HashService: Edge cases", () { - test("handles empty list of assets", () async { - when(() => mockDeviceAssetRepository.getByIds(any())) - .thenAnswer((_) async => []); + test('handles mixed success and failure in batch', () async { + final album = LocalAlbumStub.recent; + final asset1 = LocalAssetStub.image1; + final asset2 = LocalAssetStub.image2; + final mockFile1 = MockFile(); + final mockFile2 = MockFile(); + when(() => mockFile1.length()).thenAnswer((_) async => 100); + when(() => mockFile1.path).thenReturn('path-1'); + when(() => mockFile2.length()).thenAnswer((_) async => 100); + when(() => mockFile2.path).thenReturn('path-2'); - final result = await sut.hashAssets([]); + when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]); + when(() => mockAlbumRepo.getAssetsToHash(album.id)) + .thenAnswer((_) async => [asset1, asset2]); + when(() => mockStorageRepo.getFileForAsset(asset1)) + .thenAnswer((_) async => mockFile1); + when(() => mockStorageRepo.getFileForAsset(asset2)) + .thenAnswer((_) async => mockFile2); - verifyNever(() => mockBackgroundService.digestFiles(any())); - verifyNever(() => mockDeviceAssetRepository.updateAll(any())); - verifyNever(() => mockDeviceAssetRepository.deleteIds(any())); + final validHash = Uint8List.fromList(List.generate(20, (i) => i)); + when(() => mockNativeApi.hashPaths(['path-1', 'path-2'])) + .thenAnswer((_) async => [validHash, null]); + when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); - expect(result, isEmpty); - }); + await sut.hashAssets(); - test("handles all file access failures", () async { - // No DB entries - when( - () => mockDeviceAssetRepository.getByIds( - [AssetStub.image1.localId!, AssetStub.image2.localId!], - ), - ).thenAnswer((_) async => []); - - final result = await sut.hashAssets([ - AssetStub.image1, - AssetStub.image2, - ]); - - verifyNever(() => mockBackgroundService.digestFiles(any())); - verifyNever(() => mockDeviceAssetRepository.updateAll(any())); - expect(result, isEmpty); - }); + final captured = verify(() => mockAssetRepo.updateHashes(captureAny())) + .captured + .first as List; + expect(captured.length, 1); + expect(captured.first.id, asset1.id); }); }); } - -Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock( - Asset asset, -) async { - final random = Random(); - final hash = - Uint8List.fromList(List.generate(20, (i) => random.nextInt(255))); - final mockAsset = MockAsset(); - final mockAssetEntity = MockAssetEntity(); - final fs = MemoryFileSystem(); - final deviceAsset = DeviceAsset( - assetId: asset.localId!, - hash: Uint8List.fromList(hash), - modifiedTime: DateTime.now(), - ); - final tmp = await fs.systemTempDirectory.createTemp(); - final file = tmp.childFile("${asset.fileName}-path"); - await file.writeAsString("${asset.fileName}-content"); - - when(() => mockAsset.localId).thenReturn(asset.localId); - when(() => mockAsset.fileName).thenReturn(asset.fileName); - when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt); - when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt); - when(() => mockAsset.copyWith(checksum: any(named: "checksum"))) - .thenReturn(asset.copyWith(checksum: base64.encode(hash))); - when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity); - when(() => mockAssetEntity.originFile).thenAnswer((_) async => file); - - return (mockAsset, file, deviceAsset, hash); -} diff --git a/mobile/test/fixtures/album.stub.dart b/mobile/test/fixtures/album.stub.dart index c6ea199c0f..1432d35901 100644 --- a/mobile/test/fixtures/album.stub.dart +++ b/mobile/test/fixtures/album.stub.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; @@ -101,3 +102,16 @@ final class AlbumStub { endDate: DateTime(2026), ); } + +abstract final class LocalAlbumStub { + const LocalAlbumStub._(); + + static final recent = LocalAlbum( + id: "recent-local-id", + name: "Recent", + updatedAt: DateTime(2023), + assetCount: 1000, + backupSelection: BackupSelection.none, + isIosSharedAlbum: false, + ); +} diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index 771b2dda96..8d92011999 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -1,10 +1,11 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart' as old; final class AssetStub { const AssetStub._(); - static final image1 = Asset( + static final image1 = old.Asset( checksum: "image1-checksum", localId: "image1", remoteId: 'image1-remote', @@ -13,7 +14,7 @@ final class AssetStub { fileModifiedAt: DateTime(2020), updatedAt: DateTime.now(), durationInSeconds: 0, - type: AssetType.image, + type: old.AssetType.image, fileName: "image1.jpg", isFavorite: true, isArchived: false, @@ -21,7 +22,7 @@ final class AssetStub { exifInfo: const ExifInfo(isFlipped: false), ); - static final image2 = Asset( + static final image2 = old.Asset( checksum: "image2-checksum", localId: "image2", remoteId: 'image2-remote', @@ -30,7 +31,7 @@ final class AssetStub { fileModifiedAt: DateTime(2010), updatedAt: DateTime.now(), durationInSeconds: 60, - type: AssetType.video, + type: old.AssetType.video, fileName: "image2.jpg", isFavorite: false, isArchived: false, @@ -38,7 +39,7 @@ final class AssetStub { exifInfo: const ExifInfo(isFlipped: true), ); - static final image3 = Asset( + static final image3 = old.Asset( checksum: "image3-checksum", localId: "image3", ownerId: 1, @@ -46,10 +47,30 @@ final class AssetStub { fileModifiedAt: DateTime(2025), updatedAt: DateTime.now(), durationInSeconds: 60, - type: AssetType.image, + type: old.AssetType.image, fileName: "image3.jpg", isFavorite: true, isArchived: false, isTrashed: false, ); } + +abstract final class LocalAssetStub { + const LocalAssetStub._(); + + static final image1 = LocalAsset( + id: "image1", + name: "image1.jpg", + type: AssetType.image, + createdAt: DateTime(2025), + updatedAt: DateTime(2025, 2), + ); + + static final image2 = LocalAsset( + id: "image2", + name: "image2.jpg", + type: AssetType.image, + createdAt: DateTime(2000), + updatedAt: DateTime(20021), + ); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index c4a5680f71..0dc241ca94 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,5 +1,8 @@ import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; +import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/storage.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart'; import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart'; @@ -18,6 +21,12 @@ class MockDeviceAssetRepository extends Mock class MockSyncStreamRepository extends Mock implements ISyncStreamRepository {} +class MockLocalAlbumRepository extends Mock implements ILocalAlbumRepository {} + +class MockLocalAssetRepository extends Mock implements ILocalAssetRepository {} + +class MockStorageRepository extends Mock implements IStorageRepository {} + // API Repos class MockUserApiRepository extends Mock implements IUserApiRepository {} diff --git a/mobile/test/services/hash_service_test.dart b/mobile/test/services/hash_service_test.dart new file mode 100644 index 0000000000..e278199e4f --- /dev/null +++ b/mobile/test/services/hash_service_test.dart @@ -0,0 +1,425 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:file/memory.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/device_asset.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:photo_manager/photo_manager.dart'; + +import '../fixtures/asset.stub.dart'; +import '../infrastructure/repository.mock.dart'; +import '../service.mocks.dart'; + +class MockAsset extends Mock implements Asset {} + +class MockAssetEntity extends Mock implements AssetEntity {} + +void main() { + late HashService sut; + late BackgroundService mockBackgroundService; + late IDeviceAssetRepository mockDeviceAssetRepository; + + setUp(() { + mockBackgroundService = MockBackgroundService(); + mockDeviceAssetRepository = MockDeviceAssetRepository(); + + sut = HashService( + deviceAssetRepository: mockDeviceAssetRepository, + backgroundService: mockBackgroundService, + ); + + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?)?.call(); + }); + when(() => mockDeviceAssetRepository.updateAll(any())) + .thenAnswer((_) async => true); + when(() => mockDeviceAssetRepository.deleteIds(any())) + .thenAnswer((_) async => true); + }); + + group("HashService: No DeviceAsset entry", () { + test("hash successfully", () async { + final (mockAsset, file, deviceAsset, hash) = + await _createAssetMock(AssetStub.image1); + + when(() => mockBackgroundService.digestFiles([file.path])) + .thenAnswer((_) async => [hash]); + // No DB entries for this asset + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer((_) async => []); + + final result = await sut.hashAssets([mockAsset]); + + // Verify we stored the new hash in DB + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?) + ?.call(); + verify( + () => mockDeviceAssetRepository.updateAll([ + deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt), + ]), + ).called(1); + verify(() => mockDeviceAssetRepository.deleteIds([])).called(1); + }); + expect( + result, + [AssetStub.image1.copyWith(checksum: base64.encode(hash))], + ); + }); + }); + + group("HashService: Has DeviceAsset entry", () { + test("when the asset is not modified", () async { + final hash = utf8.encode("image1-hash"); + + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer( + (_) async => [ + DeviceAsset( + assetId: AssetStub.image1.localId!, + hash: hash, + modifiedTime: AssetStub.image1.fileModifiedAt, + ), + ], + ); + final result = await sut.hashAssets([AssetStub.image1]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockBackgroundService.digestFile(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + verifyNever(() => mockDeviceAssetRepository.deleteIds(any())); + + expect(result, [ + AssetStub.image1.copyWith(checksum: base64.encode(hash)), + ]); + }); + + test("hashed successful when asset is modified", () async { + final (mockAsset, file, deviceAsset, hash) = + await _createAssetMock(AssetStub.image1); + + when(() => mockBackgroundService.digestFiles([file.path])) + .thenAnswer((_) async => [hash]); + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer((_) async => [deviceAsset]); + + final result = await sut.hashAssets([mockAsset]); + + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?) + ?.call(); + verify( + () => mockDeviceAssetRepository.updateAll([ + deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt), + ]), + ).called(1); + verify(() => mockDeviceAssetRepository.deleteIds([])).called(1); + }); + + verify(() => mockBackgroundService.digestFiles([file.path])).called(1); + + expect(result, [ + AssetStub.image1.copyWith(checksum: base64.encode(hash)), + ]); + }); + }); + + group("HashService: Cleanup", () { + late Asset mockAsset; + late Uint8List hash; + late DeviceAsset deviceAsset; + late File file; + + setUp(() async { + (mockAsset, file, deviceAsset, hash) = + await _createAssetMock(AssetStub.image1); + + when(() => mockBackgroundService.digestFiles([file.path])) + .thenAnswer((_) async => [hash]); + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer((_) async => [deviceAsset]); + }); + + test("cleanups DeviceAsset when local file cannot be obtained", () async { + when(() => mockAsset.local).thenThrow(Exception("File not found")); + final result = await sut.hashAssets([mockAsset]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockBackgroundService.digestFile(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + verify( + () => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]), + ).called(1); + + expect(result, isEmpty); + }); + + test("cleanups DeviceAsset when hashing failed", () async { + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?) + ?.call(); + + // Verify the callback inside the transaction because, doing it outside results + // in a small delay before the callback is invoked, resulting in other LOCs getting executed + // resulting in an incorrect state + // + // i.e, consider the following piece of code + // await _deviceAssetRepository.transaction(() async { + // await _deviceAssetRepository.updateAll(toBeAdded); + // await _deviceAssetRepository.deleteIds(toBeDeleted); + // }); + // toBeDeleted.clear(); + // since the transaction method is mocked, the callback is not invoked until it is captured + // and executed manually in the next event loop. However, the toBeDeleted.clear() is executed + // immediately once the transaction stub is executed, resulting in the deleteIds method being + // called with an empty list. + // + // To avoid this, we capture the callback and execute it within the transaction stub itself + // and verify the results inside the transaction stub + verify(() => mockDeviceAssetRepository.updateAll([])).called(1); + verify( + () => + mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]), + ).called(1); + }); + + when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer( + // Invalid hash, length != 20 + (_) async => [Uint8List.fromList(hash.slice(2).toList())], + ); + + final result = await sut.hashAssets([mockAsset]); + + verify(() => mockBackgroundService.digestFiles([file.path])).called(1); + expect(result, isEmpty); + }); + }); + + group("HashService: Batch processing", () { + test("processes assets in batches when size limit is reached", () async { + // Setup multiple assets with large file sizes + final (mock1, mock2, mock3) = await ( + _createAssetMock(AssetStub.image1), + _createAssetMock(AssetStub.image2), + _createAssetMock(AssetStub.image3), + ).wait; + + final (asset1, file1, deviceAsset1, hash1) = mock1; + final (asset2, file2, deviceAsset2, hash2) = mock2; + final (asset3, file3, deviceAsset3, hash3) = mock3; + + when(() => mockDeviceAssetRepository.getByIds(any())) + .thenAnswer((_) async => []); + + // Setup for multiple batch processing calls + when(() => mockBackgroundService.digestFiles([file1.path, file2.path])) + .thenAnswer((_) async => [hash1, hash2]); + when(() => mockBackgroundService.digestFiles([file3.path])) + .thenAnswer((_) async => [hash3]); + + final size = await file1.length() + await file2.length(); + + sut = HashService( + deviceAssetRepository: mockDeviceAssetRepository, + backgroundService: mockBackgroundService, + batchSizeLimit: size, + ); + final result = await sut.hashAssets([asset1, asset2, asset3]); + + // Verify multiple batch process calls + verify(() => mockBackgroundService.digestFiles([file1.path, file2.path])) + .called(1); + verify(() => mockBackgroundService.digestFiles([file3.path])).called(1); + + expect( + result, + [ + AssetStub.image1.copyWith(checksum: base64.encode(hash1)), + AssetStub.image2.copyWith(checksum: base64.encode(hash2)), + AssetStub.image3.copyWith(checksum: base64.encode(hash3)), + ], + ); + }); + + test("processes assets in batches when file limit is reached", () async { + // Setup multiple assets with large file sizes + final (mock1, mock2, mock3) = await ( + _createAssetMock(AssetStub.image1), + _createAssetMock(AssetStub.image2), + _createAssetMock(AssetStub.image3), + ).wait; + + final (asset1, file1, deviceAsset1, hash1) = mock1; + final (asset2, file2, deviceAsset2, hash2) = mock2; + final (asset3, file3, deviceAsset3, hash3) = mock3; + + when(() => mockDeviceAssetRepository.getByIds(any())) + .thenAnswer((_) async => []); + + when(() => mockBackgroundService.digestFiles([file1.path])) + .thenAnswer((_) async => [hash1]); + when(() => mockBackgroundService.digestFiles([file2.path])) + .thenAnswer((_) async => [hash2]); + when(() => mockBackgroundService.digestFiles([file3.path])) + .thenAnswer((_) async => [hash3]); + + sut = HashService( + deviceAssetRepository: mockDeviceAssetRepository, + backgroundService: mockBackgroundService, + batchFileLimit: 1, + ); + final result = await sut.hashAssets([asset1, asset2, asset3]); + + // Verify multiple batch process calls + verify(() => mockBackgroundService.digestFiles([file1.path])).called(1); + verify(() => mockBackgroundService.digestFiles([file2.path])).called(1); + verify(() => mockBackgroundService.digestFiles([file3.path])).called(1); + + expect( + result, + [ + AssetStub.image1.copyWith(checksum: base64.encode(hash1)), + AssetStub.image2.copyWith(checksum: base64.encode(hash2)), + AssetStub.image3.copyWith(checksum: base64.encode(hash3)), + ], + ); + }); + + test("HashService: Sort & Process different states", () async { + final (asset1, file1, deviceAsset1, hash1) = + await _createAssetMock(AssetStub.image1); // Will need rehashing + final (asset2, file2, deviceAsset2, hash2) = + await _createAssetMock(AssetStub.image2); // Will have matching hash + final (asset3, file3, deviceAsset3, hash3) = + await _createAssetMock(AssetStub.image3); // No DB entry + final asset4 = + AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed + + when(() => mockBackgroundService.digestFiles([file1.path, file3.path])) + .thenAnswer((_) async => [hash1, hash3]); + // DB entries are not sorted and a dummy entry added + when( + () => mockDeviceAssetRepository.getByIds([ + AssetStub.image1.localId!, + AssetStub.image2.localId!, + AssetStub.image3.localId!, + asset4.localId!, + ]), + ).thenAnswer( + (_) async => [ + // Same timestamp to reuse deviceAsset + deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt), + deviceAsset1, + deviceAsset3.copyWith(assetId: asset4.localId!), + ], + ); + + final result = await sut.hashAssets([asset1, asset2, asset3, asset4]); + + // Verify correct processing of all assets + verify(() => mockBackgroundService.digestFiles([file1.path, file3.path])) + .called(1); + expect(result.length, 3); + expect(result, [ + AssetStub.image2.copyWith(checksum: base64.encode(hash2)), + AssetStub.image1.copyWith(checksum: base64.encode(hash1)), + AssetStub.image3.copyWith(checksum: base64.encode(hash3)), + ]); + }); + + group("HashService: Edge cases", () { + test("handles empty list of assets", () async { + when(() => mockDeviceAssetRepository.getByIds(any())) + .thenAnswer((_) async => []); + + final result = await sut.hashAssets([]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + verifyNever(() => mockDeviceAssetRepository.deleteIds(any())); + + expect(result, isEmpty); + }); + + test("handles all file access failures", () async { + // No DB entries + when( + () => mockDeviceAssetRepository.getByIds( + [AssetStub.image1.localId!, AssetStub.image2.localId!], + ), + ).thenAnswer((_) async => []); + + final result = await sut.hashAssets([ + AssetStub.image1, + AssetStub.image2, + ]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + expect(result, isEmpty); + }); + }); + }); +} + +Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock( + Asset asset, +) async { + final random = Random(); + final hash = + Uint8List.fromList(List.generate(20, (i) => random.nextInt(255))); + final mockAsset = MockAsset(); + final mockAssetEntity = MockAssetEntity(); + final fs = MemoryFileSystem(); + final deviceAsset = DeviceAsset( + assetId: asset.localId!, + hash: Uint8List.fromList(hash), + modifiedTime: DateTime.now(), + ); + final tmp = await fs.systemTempDirectory.createTemp(); + final file = tmp.childFile("${asset.fileName}-path"); + await file.writeAsString("${asset.fileName}-content"); + + when(() => mockAsset.localId).thenReturn(asset.localId); + when(() => mockAsset.fileName).thenReturn(asset.fileName); + when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt); + when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt); + when(() => mockAsset.copyWith(checksum: any(named: "checksum"))) + .thenReturn(asset.copyWith(checksum: base64.encode(hash))); + when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity); + when(() => mockAssetEntity.originFile).thenAnswer((_) async => file); + + return (mockAsset, file, deviceAsset, hash); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 09c0143e80..0e35be2ee0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7343,6 +7343,7 @@ "name": "albumId", "required": false, "in": "query", + "description": "Filter assets belonging to a specific album", "schema": { "format": "uuid", "type": "string" @@ -7352,6 +7353,7 @@ "name": "isFavorite", "required": false, "in": "query", + "description": "Filter by favorite status (true for favorites only, false for non-favorites only)", "schema": { "type": "boolean" } @@ -7360,6 +7362,7 @@ "name": "isTrashed", "required": false, "in": "query", + "description": "Filter by trash status (true for trashed assets only, false for non-trashed only)", "schema": { "type": "boolean" } @@ -7376,6 +7379,7 @@ "name": "order", "required": false, "in": "query", + "description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)", "schema": { "$ref": "#/components/schemas/AssetOrder" } @@ -7384,6 +7388,7 @@ "name": "personId", "required": false, "in": "query", + "description": "Filter assets containing a specific person (face recognition)", "schema": { "format": "uuid", "type": "string" @@ -7393,6 +7398,7 @@ "name": "tagId", "required": false, "in": "query", + "description": "Filter assets with a specific tag", "schema": { "format": "uuid", "type": "string" @@ -7402,7 +7408,9 @@ "name": "timeBucket", "required": true, "in": "query", + "description": "Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)", "schema": { + "example": "2024-01-01", "type": "string" } }, @@ -7410,6 +7418,7 @@ "name": "userId", "required": false, "in": "query", + "description": "Filter assets by specific user ID", "schema": { "format": "uuid", "type": "string" @@ -7419,6 +7428,7 @@ "name": "visibility", "required": false, "in": "query", + "description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)", "schema": { "$ref": "#/components/schemas/AssetVisibility" } @@ -7427,6 +7437,7 @@ "name": "withPartners", "required": false, "in": "query", + "description": "Include assets shared by partners", "schema": { "type": "boolean" } @@ -7435,6 +7446,7 @@ "name": "withStacked", "required": false, "in": "query", + "description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.", "schema": { "type": "boolean" } @@ -7476,6 +7488,7 @@ "name": "albumId", "required": false, "in": "query", + "description": "Filter assets belonging to a specific album", "schema": { "format": "uuid", "type": "string" @@ -7485,6 +7498,7 @@ "name": "isFavorite", "required": false, "in": "query", + "description": "Filter by favorite status (true for favorites only, false for non-favorites only)", "schema": { "type": "boolean" } @@ -7493,6 +7507,7 @@ "name": "isTrashed", "required": false, "in": "query", + "description": "Filter by trash status (true for trashed assets only, false for non-trashed only)", "schema": { "type": "boolean" } @@ -7509,6 +7524,7 @@ "name": "order", "required": false, "in": "query", + "description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)", "schema": { "$ref": "#/components/schemas/AssetOrder" } @@ -7517,6 +7533,7 @@ "name": "personId", "required": false, "in": "query", + "description": "Filter assets containing a specific person (face recognition)", "schema": { "format": "uuid", "type": "string" @@ -7526,6 +7543,7 @@ "name": "tagId", "required": false, "in": "query", + "description": "Filter assets with a specific tag", "schema": { "format": "uuid", "type": "string" @@ -7535,6 +7553,7 @@ "name": "userId", "required": false, "in": "query", + "description": "Filter assets by specific user ID", "schema": { "format": "uuid", "type": "string" @@ -7544,6 +7563,7 @@ "name": "visibility", "required": false, "in": "query", + "description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)", "schema": { "$ref": "#/components/schemas/AssetVisibility" } @@ -7552,6 +7572,7 @@ "name": "withPartners", "required": false, "in": "query", + "description": "Include assets shared by partners", "schema": { "type": "boolean" } @@ -7560,6 +7581,7 @@ "name": "withStacked", "required": false, "in": "query", + "description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.", "schema": { "type": "boolean" } @@ -8695,6 +8717,34 @@ ], "type": "string" }, + "AlbumsResponse": { + "properties": { + "defaultAssetOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ], + "default": "desc" + } + }, + "required": [ + "defaultAssetOrder" + ], + "type": "object" + }, + "AlbumsUpdate": { + "properties": { + "defaultAssetOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] + } + }, + "type": "object" + }, "AllJobStatusResponseDto": { "properties": { "backgroundTask": { @@ -9369,10 +9419,14 @@ "$ref": "#/components/schemas/ExifResponseDto" }, "fileCreatedAt": { + "description": "The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.", + "example": "2024-01-15T19:30:00.000Z", "format": "date-time", "type": "string" }, "fileModifiedAt": { + "description": "The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.", + "example": "2024-01-16T10:15:00.000Z", "format": "date-time", "type": "string" }, @@ -9405,6 +9459,8 @@ "type": "string" }, "localDateTime": { + "description": "The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months.", + "example": "2024-01-15T14:30:00.000Z", "format": "date-time", "type": "string" }, @@ -9466,6 +9522,8 @@ "type": "array" }, "updatedAt": { + "description": "The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.", + "example": "2024-01-16T12:45:30.000Z", "format": "date-time", "type": "string" }, @@ -14424,6 +14482,7 @@ "TimeBucketAssetResponseDto": { "properties": { "city": { + "description": "Array of city names extracted from EXIF GPS data", "items": { "nullable": true, "type": "string" @@ -14431,6 +14490,7 @@ "type": "array" }, "country": { + "description": "Array of country names extracted from EXIF GPS data", "items": { "nullable": true, "type": "string" @@ -14438,56 +14498,72 @@ "type": "array" }, "duration": { + "description": "Array of video durations in HH:MM:SS format (null for images)", "items": { "nullable": true, "type": "string" }, "type": "array" }, + "fileCreatedAt": { + "description": "Array of file creation timestamps in UTC (ISO 8601 format, without timezone)", + "items": { + "type": "string" + }, + "type": "array" + }, "id": { + "description": "Array of asset IDs in the time bucket", "items": { "type": "string" }, "type": "array" }, "isFavorite": { + "description": "Array indicating whether each asset is favorited", "items": { "type": "boolean" }, "type": "array" }, "isImage": { + "description": "Array indicating whether each asset is an image (false for videos)", "items": { "type": "boolean" }, "type": "array" }, "isTrashed": { + "description": "Array indicating whether each asset is in the trash", "items": { "type": "boolean" }, "type": "array" }, "livePhotoVideoId": { + "description": "Array of live photo video asset IDs (null for non-live photos)", "items": { "nullable": true, "type": "string" }, "type": "array" }, - "localDateTime": { + "localOffsetHours": { + "description": "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", "items": { - "type": "string" + "type": "number" }, "type": "array" }, "ownerId": { + "description": "Array of owner IDs for each asset", "items": { "type": "string" }, "type": "array" }, "projectionType": { + "description": "Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\")", "items": { "nullable": true, "type": "string" @@ -14495,13 +14571,14 @@ "type": "array" }, "ratio": { + "description": "Array of aspect ratios (width/height) for each asset", "items": { "type": "number" }, "type": "array" }, "stack": { - "description": "(stack ID, stack asset count) tuple", + "description": "Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)", "items": { "items": { "type": "string" @@ -14514,6 +14591,7 @@ "type": "array" }, "thumbhash": { + "description": "Array of BlurHash strings for generating asset previews (base64 encoded)", "items": { "nullable": true, "type": "string" @@ -14521,6 +14599,7 @@ "type": "array" }, "visibility": { + "description": "Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)", "items": { "$ref": "#/components/schemas/AssetVisibility" }, @@ -14531,12 +14610,13 @@ "city", "country", "duration", + "fileCreatedAt", "id", "isFavorite", "isImage", "isTrashed", "livePhotoVideoId", - "localDateTime", + "localOffsetHours", "ownerId", "projectionType", "ratio", @@ -14548,9 +14628,13 @@ "TimeBucketsResponseDto": { "properties": { "count": { + "description": "Number of assets in this time bucket", + "example": 42, "type": "integer" }, "timeBucket": { + "description": "Time bucket identifier in YYYY-MM-DD format representing the start of the time period", + "example": "2024-01-01", "type": "string" } }, @@ -14984,6 +15068,9 @@ }, "UserPreferencesResponseDto": { "properties": { + "albums": { + "$ref": "#/components/schemas/AlbumsResponse" + }, "cast": { "$ref": "#/components/schemas/CastResponse" }, @@ -15016,6 +15103,7 @@ } }, "required": [ + "albums", "cast", "download", "emailNotifications", @@ -15031,6 +15119,9 @@ }, "UserPreferencesUpdateDto": { "properties": { + "albums": { + "$ref": "#/components/schemas/AlbumsUpdate" + }, "avatar": { "$ref": "#/components/schemas/AvatarUpdate" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b390bf7477..fa75049168 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -129,6 +129,9 @@ export type UserAdminUpdateDto = { shouldChangePassword?: boolean; storageLabel?: string | null; }; +export type AlbumsResponse = { + defaultAssetOrder: AssetOrder; +}; export type CastResponse = { gCastEnabled: boolean; }; @@ -168,6 +171,7 @@ export type TagsResponse = { sidebarWeb: boolean; }; export type UserPreferencesResponseDto = { + albums: AlbumsResponse; cast: CastResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; @@ -179,6 +183,9 @@ export type UserPreferencesResponseDto = { sharedLinks: SharedLinksResponse; tags: TagsResponse; }; +export type AlbumsUpdate = { + defaultAssetOrder?: AssetOrder; +}; export type AvatarUpdate = { color?: UserAvatarColor; }; @@ -221,6 +228,7 @@ export type TagsUpdate = { sidebarWeb?: boolean; }; export type UserPreferencesUpdateDto = { + albums?: AlbumsUpdate; avatar?: AvatarUpdate; cast?: CastUpdate; download?: DownloadUpdate; @@ -312,7 +320,9 @@ export type AssetResponseDto = { duplicateId?: string | null; duration: string; exifInfo?: ExifResponseDto; + /** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */ fileCreatedAt: string; + /** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */ fileModifiedAt: string; hasMetadata: boolean; id: string; @@ -323,6 +333,7 @@ export type AssetResponseDto = { /** This property was deprecated in v1.106.0 */ libraryId?: string | null; livePhotoVideoId?: string | null; + /** The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months. */ localDateTime: string; originalFileName: string; originalMimeType?: string; @@ -337,6 +348,7 @@ export type AssetResponseDto = { thumbhash: string | null; "type": AssetTypeEnum; unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; + /** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */ updatedAt: string; visibility: AssetVisibility; }; @@ -1442,25 +1454,43 @@ export type TagUpdateDto = { color?: string | null; }; export type TimeBucketAssetResponseDto = { + /** Array of city names extracted from EXIF GPS data */ city: (string | null)[]; + /** Array of country names extracted from EXIF GPS data */ country: (string | null)[]; + /** Array of video durations in HH:MM:SS format (null for images) */ duration: (string | null)[]; + /** Array of file creation timestamps in UTC (ISO 8601 format, without timezone) */ + fileCreatedAt: string[]; + /** Array of asset IDs in the time bucket */ id: string[]; + /** Array indicating whether each asset is favorited */ isFavorite: boolean[]; + /** Array indicating whether each asset is an image (false for videos) */ isImage: boolean[]; + /** Array indicating whether each asset is in the trash */ isTrashed: boolean[]; + /** Array of live photo video asset IDs (null for non-live photos) */ livePhotoVideoId: (string | null)[]; - localDateTime: string[]; + /** Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. */ + localOffsetHours: number[]; + /** Array of owner IDs for each asset */ ownerId: string[]; + /** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */ projectionType: (string | null)[]; + /** Array of aspect ratios (width/height) for each asset */ ratio: number[]; - /** (stack ID, stack asset count) tuple */ + /** Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets) */ stack?: (string[] | null)[]; + /** Array of BlurHash strings for generating asset previews (base64 encoded) */ thumbhash: (string | null)[]; + /** Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED) */ visibility: AssetVisibility[]; }; export type TimeBucketsResponseDto = { + /** Number of assets in this time bucket */ count: number; + /** Time bucket identifier in YYYY-MM-DD format representing the start of the time period */ timeBucket: string; }; export type TrashResponseDto = { @@ -3727,6 +3757,10 @@ export enum UserStatus { Removing = "removing", Deleted = "deleted" } +export enum AssetOrder { + Asc = "asc", + Desc = "desc" +} export enum AssetVisibility { Archive = "archive", Timeline = "timeline", @@ -3748,10 +3782,6 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } -export enum AssetOrder { - Asc = "asc", - Desc = "desc" -} export enum Error { Duplicate = "duplicate", NoPermission = "no_permission", diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 9bbfb450b2..1e214c3860 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -22,6 +22,13 @@ export class SanitizedAssetResponseDto { type!: AssetType; thumbhash!: string | null; originalMimeType?: string; + @ApiProperty({ + type: 'string', + format: 'date-time', + description: + 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', + example: '2024-01-15T14:30:00.000Z', + }) localDateTime!: Date; duration!: string; livePhotoVideoId?: string | null; @@ -37,8 +44,29 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { libraryId?: string | null; originalPath!: string; originalFileName!: string; + @ApiProperty({ + type: 'string', + format: 'date-time', + description: + 'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.', + example: '2024-01-15T19:30:00.000Z', + }) fileCreatedAt!: Date; + @ApiProperty({ + type: 'string', + format: 'date-time', + description: + 'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.', + example: '2024-01-16T10:15:00.000Z', + }) fileModifiedAt!: Date; + @ApiProperty({ + type: 'string', + format: 'date-time', + description: + 'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.', + example: '2024-01-16T12:45:30.000Z', + }) updatedAt!: Date; isFavorite!: boolean; isArchived!: boolean; diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 3f4157babb..af2eae7e72 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -5,72 +5,143 @@ import { AssetOrder, AssetVisibility } from 'src/enum'; import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { - @ValidateUUID({ optional: true }) + @ValidateUUID({ optional: true, description: 'Filter assets by specific user ID' }) userId?: string; - @ValidateUUID({ optional: true }) + @ValidateUUID({ optional: true, description: 'Filter assets belonging to a specific album' }) albumId?: string; - @ValidateUUID({ optional: true }) + @ValidateUUID({ optional: true, description: 'Filter assets containing a specific person (face recognition)' }) personId?: string; - @ValidateUUID({ optional: true }) + @ValidateUUID({ optional: true, description: 'Filter assets with a specific tag' }) tagId?: string; - @ValidateBoolean({ optional: true }) + @ValidateBoolean({ + optional: true, + description: 'Filter by favorite status (true for favorites only, false for non-favorites only)', + }) isFavorite?: boolean; - @ValidateBoolean({ optional: true }) + @ValidateBoolean({ + optional: true, + description: 'Filter by trash status (true for trashed assets only, false for non-trashed only)', + }) isTrashed?: boolean; - @ValidateBoolean({ optional: true }) + @ValidateBoolean({ + optional: true, + description: 'Include stacked assets in the response. When true, only primary assets from stacks are returned.', + }) withStacked?: boolean; - @ValidateBoolean({ optional: true }) + @ValidateBoolean({ optional: true, description: 'Include assets shared by partners' }) withPartners?: boolean; @IsEnum(AssetOrder) @Optional() - @ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' }) + @ApiProperty({ + enum: AssetOrder, + enumName: 'AssetOrder', + description: 'Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)', + }) order?: AssetOrder; - @ValidateAssetVisibility({ optional: true }) + @ValidateAssetVisibility({ + optional: true, + description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)', + }) visibility?: AssetVisibility; } export class TimeBucketAssetDto extends TimeBucketDto { + @ApiProperty({ + type: 'string', + description: 'Time bucket identifier in YYYY-MM-DD format (e.g., "2024-01-01" for January 2024)', + example: '2024-01-01', + }) @IsString() timeBucket!: string; } -export class TimelineStackResponseDto { - id!: string; - primaryAssetId!: string; - assetCount!: number; -} - export class TimeBucketAssetResponseDto { + @ApiProperty({ + type: 'array', + items: { type: 'string' }, + description: 'Array of asset IDs in the time bucket', + }) id!: string[]; + @ApiProperty({ + type: 'array', + items: { type: 'string' }, + description: 'Array of owner IDs for each asset', + }) ownerId!: string[]; + @ApiProperty({ + type: 'array', + items: { type: 'number' }, + description: 'Array of aspect ratios (width/height) for each asset', + }) ratio!: number[]; + @ApiProperty({ + type: 'array', + items: { type: 'boolean' }, + description: 'Array indicating whether each asset is favorited', + }) isFavorite!: boolean[]; - @ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true }) + @ApiProperty({ + enum: AssetVisibility, + enumName: 'AssetVisibility', + isArray: true, + description: 'Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)', + }) visibility!: AssetVisibility[]; + @ApiProperty({ + type: 'array', + items: { type: 'boolean' }, + description: 'Array indicating whether each asset is in the trash', + }) isTrashed!: boolean[]; + @ApiProperty({ + type: 'array', + items: { type: 'boolean' }, + description: 'Array indicating whether each asset is an image (false for videos)', + }) isImage!: boolean[]; - @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + @ApiProperty({ + type: 'array', + items: { type: 'string', nullable: true }, + description: 'Array of BlurHash strings for generating asset previews (base64 encoded)', + }) thumbhash!: (string | null)[]; - localDateTime!: string[]; + @ApiProperty({ + type: 'array', + items: { type: 'string' }, + description: 'Array of file creation timestamps in UTC (ISO 8601 format, without timezone)', + }) + fileCreatedAt!: string[]; - @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + @ApiProperty({ + type: 'array', + items: { type: 'number' }, + description: + "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", + }) + localOffsetHours!: number[]; + + @ApiProperty({ + type: 'array', + items: { type: 'string', nullable: true }, + description: 'Array of video durations in HH:MM:SS format (null for images)', + }) duration!: (string | null)[]; @ApiProperty({ @@ -82,27 +153,51 @@ export class TimeBucketAssetResponseDto { maxItems: 2, nullable: true, }, - description: '(stack ID, stack asset count) tuple', + description: 'Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)', }) stack?: ([string, string] | null)[]; - @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + @ApiProperty({ + type: 'array', + items: { type: 'string', nullable: true }, + description: 'Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL")', + }) projectionType!: (string | null)[]; - @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + @ApiProperty({ + type: 'array', + items: { type: 'string', nullable: true }, + description: 'Array of live photo video asset IDs (null for non-live photos)', + }) livePhotoVideoId!: (string | null)[]; - @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + @ApiProperty({ + type: 'array', + items: { type: 'string', nullable: true }, + description: 'Array of city names extracted from EXIF GPS data', + }) city!: (string | null)[]; - @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + @ApiProperty({ + type: 'array', + items: { type: 'string', nullable: true }, + description: 'Array of country names extracted from EXIF GPS data', + }) country!: (string | null)[]; } export class TimeBucketsResponseDto { - @ApiProperty({ type: 'string' }) + @ApiProperty({ + type: 'string', + description: 'Time bucket identifier in YYYY-MM-DD format representing the start of the time period', + example: '2024-01-01', + }) timeBucket!: string; - @ApiProperty({ type: 'integer' }) + @ApiProperty({ + type: 'integer', + description: 'Number of assets in this time bucket', + example: 42, + }) count!: number; } diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 43e15689b9..6765df9f73 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; -import { UserAvatarColor } from 'src/enum'; +import { AssetOrder, UserAvatarColor } from 'src/enum'; import { UserPreferences } from 'src/types'; import { Optional, ValidateBoolean } from 'src/validation'; @@ -22,6 +22,12 @@ class RatingsUpdate { enabled?: boolean; } +class AlbumsUpdate { + @IsEnum(AssetOrder) + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + defaultAssetOrder?: AssetOrder; +} + class FoldersUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -91,6 +97,11 @@ class CastUpdate { } export class UserPreferencesUpdateDto { + @Optional() + @ValidateNested() + @Type(() => AlbumsUpdate) + albums?: AlbumsUpdate; + @Optional() @ValidateNested() @Type(() => FoldersUpdate) @@ -147,6 +158,12 @@ export class UserPreferencesUpdateDto { cast?: CastUpdate; } +class AlbumsResponse { + @IsEnum(AssetOrder) + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + defaultAssetOrder: AssetOrder = AssetOrder.DESC; +} + class RatingsResponse { enabled: boolean = false; } @@ -198,6 +215,7 @@ class CastResponse { } export class UserPreferencesResponseDto implements UserPreferences { + albums!: AlbumsResponse; folders!: FoldersResponse; memories!: MemoriesResponse; people!: PeopleResponse; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index d85ad341d0..b6c5d4bea8 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -242,7 +242,7 @@ with and "assets"."visibility" in ('archive', 'timeline') ) select - "timeBucket", + "timeBucket"::date::text as "timeBucket", count(*) as "count" from "assets" @@ -262,9 +262,16 @@ with assets.type = 'IMAGE' as "isImage", assets."deletedAt" is not null as "isTrashed", "assets"."livePhotoVideoId", - "assets"."localDateTime", + extract( + epoch + from + ( + assets."localDateTime" - assets."fileCreatedAt" at time zone 'UTC' + ) + )::real / 3600 as "localOffsetHours", "assets"."ownerId", "assets"."status", + assets."fileCreatedAt" at time zone 'utc' as "fileCreatedAt", encode("assets"."thumbhash", 'base64') as "thumbhash", "exif"."city", "exif"."country", @@ -313,7 +320,7 @@ with and "asset_stack"."primaryAssetId" != "assets"."id" ) order by - "assets"."localDateTime" desc + "assets"."fileCreatedAt" desc ), "agg" as ( select @@ -326,7 +333,8 @@ with coalesce(array_agg("isImage"), '{}') as "isImage", coalesce(array_agg("isTrashed"), '{}') as "isTrashed", coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId", - coalesce(array_agg("localDateTime"), '{}') as "localDateTime", + coalesce(array_agg("fileCreatedAt"), '{}') as "fileCreatedAt", + coalesce(array_agg("localOffsetHours"), '{}') as "localOffsetHours", coalesce(array_agg("ownerId"), '{}') as "ownerId", coalesce(array_agg("projectionType"), '{}') as "projectionType", coalesce(array_agg("ratio"), '{}') as "ratio", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 416cf4e5de..af5239ed70 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -532,51 +532,44 @@ export class AssetRepository { @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) async getTimeBuckets(options: TimeBucketOptions): Promise { - return ( - this.db - .with('assets', (qb) => - qb - .selectFrom('assets') - .select(truncatedDate(TimeBucketSize.MONTH).as('timeBucket')) - .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) - .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) - .$if(options.visibility === undefined, withDefaultVisibility) - .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) - .$if(!!options.albumId, (qb) => - qb - .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') - .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), - ) - .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) - .$if(!!options.withStacked, (qb) => - qb - .leftJoin('asset_stack', (join) => - join - .onRef('asset_stack.id', '=', 'assets.stackId') - .onRef('asset_stack.primaryAssetId', '=', 'assets.id'), - ) - .where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])), - ) - .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) - .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) - .$if(options.isDuplicate !== undefined, (qb) => - qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), - ) - .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), - ) - .selectFrom('assets') - .select('timeBucket') - /* - TODO: the above line outputs in ISO format, which bloats the response. - The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work. - .select(sql`"timeBucket"::date::text`.as('timeBucket')) - */ - .select((eb) => eb.fn.countAll().as('count')) - .groupBy('timeBucket') - .orderBy('timeBucket', options.order ?? 'desc') - .execute() as any as Promise - ); + return this.db + .with('assets', (qb) => + qb + .selectFrom('assets') + .select(truncatedDate(TimeBucketSize.MONTH).as('timeBucket')) + .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) + .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .$if(options.visibility === undefined, withDefaultVisibility) + .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) + .$if(!!options.albumId, (qb) => + qb + .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') + .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), + ) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) + .$if(!!options.withStacked, (qb) => + qb + .leftJoin('asset_stack', (join) => + join + .onRef('asset_stack.id', '=', 'assets.stackId') + .onRef('asset_stack.primaryAssetId', '=', 'assets.id'), + ) + .where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])), + ) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) + .$if(options.isDuplicate !== undefined, (qb) => + qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), + ) + .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), + ) + .selectFrom('assets') + .select(sql`"timeBucket"::date::text`.as('timeBucket')) + .select((eb) => eb.fn.countAll().as('count')) + .groupBy('timeBucket') + .orderBy('timeBucket', options.order ?? 'desc') + .execute() as any as Promise; } @GenerateSql({ @@ -596,9 +589,12 @@ export class AssetRepository { sql`assets.type = 'IMAGE'`.as('isImage'), sql`assets."deletedAt" is not null`.as('isTrashed'), 'assets.livePhotoVideoId', - 'assets.localDateTime', + sql`extract(epoch from (assets."localDateTime" - assets."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as( + 'localOffsetHours', + ), 'assets.ownerId', 'assets.status', + sql`assets."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'), eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'), 'exif.city', 'exif.country', @@ -666,7 +662,7 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .orderBy('assets.localDateTime', options.order ?? 'desc'), + .orderBy('assets.fileCreatedAt', options.order ?? 'desc'), ) .with('agg', (qb) => qb @@ -682,7 +678,8 @@ export class AssetRepository { // TODO: isTrashed is redundant as it will always be all true or false depending on the options eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'), eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'), - eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'), + eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'), + eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'), eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'), eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'), eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'), diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index f3bb7d1d5c..b42225613d 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { AlbumUserRole } from 'src/enum'; +import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum'; import { AlbumService } from 'src/services/album.service'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -141,6 +141,7 @@ describe(AlbumService.name, () => { it('creates album', async () => { mocks.album.create.mockResolvedValue(albumStub.empty); mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); await sut.create(authStub.admin, { @@ -155,7 +156,7 @@ describe(AlbumService.name, () => { ownerId: authStub.admin.user.id, albumName: albumStub.empty.albumName, description: albumStub.empty.description, - + order: 'desc', albumThumbnailAssetId: '123', }, ['123'], @@ -163,6 +164,50 @@ describe(AlbumService.name, () => { ); expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); + expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { + id: albumStub.empty.id, + userId: 'user-id', + }); + }); + + it('creates album with assetOrder from user preferences', async () => { + mocks.album.create.mockResolvedValue(albumStub.empty); + mocks.user.get.mockResolvedValue(userStub.user1); + mocks.user.getMetadata.mockResolvedValue([ + { + key: UserMetadataKey.PREFERENCES, + value: { + albums: { + defaultAssetOrder: AssetOrder.ASC, + }, + }, + }, + ]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123'])); + + await sut.create(authStub.admin, { + albumName: 'Empty album', + albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + description: '', + assetIds: ['123'], + }); + + expect(mocks.album.create).toHaveBeenCalledWith( + { + ownerId: authStub.admin.user.id, + albumName: albumStub.empty.albumName, + description: albumStub.empty.description, + order: 'asc', + albumThumbnailAssetId: '123', + }, + ['123'], + [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + ); + + expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, @@ -185,6 +230,7 @@ describe(AlbumService.name, () => { it('should only add assets the user is allowed to access', async () => { mocks.user.get.mockResolvedValue(userStub.user1); mocks.album.create.mockResolvedValue(albumStub.oneAsset); + mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.create(authStub.admin, { @@ -198,7 +244,7 @@ describe(AlbumService.name, () => { ownerId: authStub.admin.user.id, albumName: 'Test album', description: '', - + order: 'desc', albumThumbnailAssetId: 'asset-1', }, ['asset-1'], diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 83d9535505..e49d4bc5fe 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -19,6 +19,7 @@ import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { getPreferences } from 'src/utils/preferences'; @Injectable() export class AlbumService extends BaseService { @@ -106,12 +107,15 @@ export class AlbumService extends BaseService { }); const assetIds = [...allowedAssetIdsSet].map((id) => id); + const userMetadata = await this.userRepository.getMetadata(auth.user.id); + const album = await this.albumRepository.create( { ownerId: auth.user.id, albumName: dto.albumName, description: dto.description, albumThumbnailAssetId: assetIds[0] || null, + order: getPreferences(userMetadata).albums.defaultAssetOrder, }, assetIds, albumUsers, diff --git a/server/src/types.ts b/server/src/types.ts index 2e613c124e..3ef22f96ff 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,6 +1,7 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { + AssetOrder, AssetType, DatabaseSslMode, ExifOrientation, @@ -467,6 +468,9 @@ export type UserMetadataItem = { }; export interface UserPreferences { + albums: { + defaultAssetOrder: AssetOrder; + }; folders: { enabled: boolean; sidebarWeb: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 009dabce58..9bd3dedd52 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,12 +1,15 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserMetadataKey } from 'src/enum'; +import { AssetOrder, UserMetadataKey } from 'src/enum'; import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { getKeysDeep } from 'src/utils/misc'; const getDefaultPreferences = (): UserPreferences => { return { + albums: { + defaultAssetOrder: AssetOrder.DESC, + }, folders: { enabled: false, sidebarWeb: false, diff --git a/server/src/validation.ts b/server/src/validation.ts index 2d160f43ce..bacf4b6f5a 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -6,7 +6,7 @@ import { ParseUUIDPipe, applyDecorators, } from '@nestjs/common'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, @@ -72,22 +72,28 @@ export class UUIDParamDto { } type PinCodeOptions = { optional?: boolean } & OptionalOptions; -export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => { +export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => { + const { optional, nullable, emptyToNull, ...apiPropertyOptions } = { + optional: false, + nullable: false, + emptyToNull: false, + ...options, + }; const decorators = [ IsString(), IsNotEmpty(), Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }), - ApiProperty({ example: '123456' }), + ApiProperty({ example: '123456', ...apiPropertyOptions }), ]; if (optional) { - decorators.push(Optional(options)); + decorators.push(Optional({ nullable, emptyToNull })); } return applyDecorators(...decorators); }; -export interface OptionalOptions extends ValidationOptions { +export interface OptionalOptions { nullable?: boolean; /** convert empty strings to null */ emptyToNull?: boolean; @@ -127,22 +133,32 @@ export const ValidateHexColor = () => { }; type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; -export const ValidateUUID = (options?: UUIDOptions) => { - const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options }; +export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { + const { optional, each, nullable, ...apiPropertyOptions } = { + optional: false, + each: false, + nullable: false, + ...options, + }; return applyDecorators( IsUUID('4', { each }), - ApiProperty({ format: 'uuid' }), + ApiProperty({ format: 'uuid', ...apiPropertyOptions }), optional ? Optional({ nullable }) : IsNotEmpty(), each ? IsArray() : IsString(), ); }; type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; -export const ValidateDate = (options?: DateOptions) => { - const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; +export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { + const { optional, nullable, format, ...apiPropertyOptions } = { + optional: false, + nullable: false, + format: 'date-time', + ...options, + }; const decorators = [ - ApiProperty({ format }), + ApiProperty({ format, ...apiPropertyOptions }), IsDate(), optional ? Optional({ nullable: true }) : IsNotEmpty(), Transform(({ key, value }) => { @@ -166,9 +182,12 @@ export const ValidateDate = (options?: DateOptions) => { }; type AssetVisibilityOptions = { optional?: boolean }; -export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { - const { optional } = { optional: false, ...options }; - const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })]; +export const ValidateAssetVisibility = (options?: AssetVisibilityOptions & ApiPropertyOptions) => { + const { optional, ...apiPropertyOptions } = { optional: false, ...options }; + const decorators = [ + IsEnum(AssetVisibility), + ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility, ...apiPropertyOptions }), + ]; if (optional) { decorators.push(Optional()); @@ -177,10 +196,10 @@ export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { }; type BooleanOptions = { optional?: boolean }; -export const ValidateBoolean = (options?: BooleanOptions) => { - const { optional } = { optional: false, ...options }; +export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => { + const { optional, ...apiPropertyOptions } = { optional: false, ...options }; const decorators = [ - // ApiProperty(), + ApiProperty(apiPropertyOptions), IsBoolean(), Transform(({ value }) => { if (value == 'true') { diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index de8e355d33..c8adabd055 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -18,7 +18,7 @@ import { getByteUnitString } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; - import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util'; + import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo, @@ -112,8 +112,8 @@ let timeZone = $derived(asset.exifInfo?.timeZone); let dateTime = $derived( timeZone && asset.exifInfo?.dateTimeOriginal - ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone) - : fromLocalDateTime(asset.localDateTime), + ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) + : fromISODateTimeUTC(asset.localDateTime), ); const getMegapixel = (width: number, height: number): number | undefined => { diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 62e74f5685..b36082b500 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -35,7 +35,7 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util'; + import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; import { @@ -576,7 +576,7 @@

- {fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { + {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { locale: $locale, })}

diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index b7db2c92a2..18174e2749 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -4,14 +4,18 @@ NotificationType, } from '$lib/components/shared-components/notification/notification'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { preferences } from '$lib/stores/user.store'; - import { updateMyPreferences } from '@immich/sdk'; + import { AssetOrder, updateMyPreferences } from '@immich/sdk'; import { Button } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; + // Albums + let defaultAssetOrder = $state($preferences?.albums?.defaultAssetOrder ?? AssetOrder.Desc); + // Folders let foldersEnabled = $state($preferences?.folders?.enabled ?? false); let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false); @@ -41,6 +45,7 @@ try { const data = await updateMyPreferences({ userPreferencesUpdateDto: { + albums: { defaultAssetOrder }, folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar }, memories: { enabled: memoriesEnabled }, people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar }, @@ -68,6 +73,20 @@
+ +
+ +
+
+
diff --git a/web/src/lib/managers/timeline-manager/asset-bucket.svelte.ts b/web/src/lib/managers/timeline-manager/asset-bucket.svelte.ts index 08607d4331..655bffc31a 100644 --- a/web/src/lib/managers/timeline-manager/asset-bucket.svelte.ts +++ b/web/src/lib/managers/timeline-manager/asset-bucket.svelte.ts @@ -3,10 +3,10 @@ import { handleError } from '$lib/utils/handle-error'; import { formatBucketTitle, formatGroupTitle, - fromLocalDateTimeToObject, fromTimelinePlainDate, fromTimelinePlainDateTime, fromTimelinePlainYearMonth, + getTimes, type TimelinePlainDateTime, type TimelinePlainYearMonth, } from '$lib/utils/timeline-util'; @@ -153,8 +153,12 @@ export class AssetBucket { addAssets(bucketAssets: TimeBucketAssetResponseDto) { const addContext = new AddContext(); - const people: string[] = []; for (let i = 0; i < bucketAssets.id.length; i++) { + const { localDateTime, fileCreatedAt } = getTimes( + bucketAssets.fileCreatedAt[i], + bucketAssets.localOffsetHours[i], + ); + const timelineAsset: TimelineAsset = { city: bucketAssets.city[i], country: bucketAssets.country[i], @@ -166,9 +170,9 @@ export class AssetBucket { isTrashed: bucketAssets.isTrashed[i], isVideo: !bucketAssets.isImage[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i], - localDateTime: fromLocalDateTimeToObject(bucketAssets.localDateTime[i]), + localDateTime, + fileCreatedAt, ownerId: bucketAssets.ownerId[i], - people, projectionType: bucketAssets.projectionType[i], ratio: bucketAssets.ratio[i], stack: bucketAssets.stack?.[i] @@ -179,6 +183,7 @@ export class AssetBucket { } : null, thumbhash: bucketAssets.thumbhash[i], + people: null, // People are not included in the bucket assets }; this.addTimelineAsset(timelineAsset, addContext); } diff --git a/web/src/lib/managers/timeline-manager/asset-date-group.svelte.ts b/web/src/lib/managers/timeline-manager/asset-date-group.svelte.ts index e27aaabbe4..f74ef2198d 100644 --- a/web/src/lib/managers/timeline-manager/asset-date-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/asset-date-group.svelte.ts @@ -72,7 +72,7 @@ export class AssetDateGroup { sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc); - this.intersectingAssets.sort((a, b) => sortFn(a.asset.localDateTime, b.asset.localDateTime)); + this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt)); } getFirstAsset() { diff --git a/web/src/lib/managers/timeline-manager/asset-store.svelte.ts b/web/src/lib/managers/timeline-manager/asset-store.svelte.ts index 620ebaed0b..6740919fef 100644 --- a/web/src/lib/managers/timeline-manager/asset-store.svelte.ts +++ b/web/src/lib/managers/timeline-manager/asset-store.svelte.ts @@ -3,7 +3,7 @@ import { websocketEvents } from '$lib/stores/websocket'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { plainDateTimeCompare, - toISOLocalDateTime, + toISOYearMonthUTC, toTimelineAsset, type TimelinePlainDate, type TimelinePlainDateTime, @@ -573,7 +573,7 @@ export class AssetStore { if (bucket.getFirstAsset()) { return; } - const timeBucket = toISOLocalDateTime(bucket.yearMonth); + const timeBucket = toISOYearMonthUTC(bucket.yearMonth); const key = authManager.key; const bucketResponse = await getTimeBucket( { diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 778ac69f26..978f935599 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -18,6 +18,7 @@ export type TimelineAsset = { ratio: number; thumbhash: string | null; localDateTime: TimelinePlainDateTime; + fileCreatedAt: TimelinePlainDateTime; visibility: AssetVisibility; isFavorite: boolean; isTrashed: boolean; @@ -29,7 +30,7 @@ export type TimelineAsset = { livePhotoVideoId: string | null; city: string | null; country: string | null; - people: string[]; + people: string[] | null; }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts index e17015ace2..621a6c37df 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { AbortError } from '$lib/utils'; -import { fromLocalDateTimeToObject } from '$lib/utils/timeline-util'; +import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; @@ -14,6 +14,13 @@ async function getAssets(store: AssetStore) { return assets; } +function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset { + return { + ...arg, + localDateTime: arg.fileCreatedAt, + }; +} + describe('AssetStore', () => { beforeEach(() => { vi.resetAllMocks(); @@ -22,15 +29,24 @@ describe('AssetStore', () => { describe('init', () => { let assetStore: AssetStore; const bucketAssets: Record = { - '2024-03-01T00:00:00.000Z': timelineAssetFactory - .buildList(1) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })), - '2024-02-01T00:00:00.000Z': timelineAssetFactory - .buildList(100) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })), - '2024-01-01T00:00:00.000Z': timelineAssetFactory - .buildList(3) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })), + '2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'), + }), + ), + '2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'), + }), + ), + '2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'), + }), + ), }; const bucketAssetsResponse: Record = Object.fromEntries( @@ -40,9 +56,9 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ - { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, - { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, - { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, + { count: 1, timeBucket: '2024-03-01' }, + { count: 100, timeBucket: '2024-02-01' }, + { count: 3, timeBucket: '2024-01-01' }, ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); @@ -78,12 +94,18 @@ describe('AssetStore', () => { describe('loadBucket', () => { let assetStore: AssetStore; const bucketAssets: Record = { - '2024-01-03T00:00:00.000Z': timelineAssetFactory - .buildList(1) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })), - '2024-01-01T00:00:00.000Z': timelineAssetFactory - .buildList(3) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })), + '2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'), + }), + ), + '2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'), + }), + ), }; const bucketAssetsResponse: Record = Object.fromEntries( Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), @@ -166,9 +188,11 @@ describe('AssetStore', () => { }); it('adds assets to new bucket', () => { - const asset = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); assetStore.addAssets([asset]); expect(assetStore.buckets.length).toEqual(1); @@ -180,9 +204,11 @@ describe('AssetStore', () => { }); it('adds assets to existing bucket', () => { - const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); + const [assetOne, assetTwo] = timelineAssetFactory + .buildList(2, { + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }) + .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); assetStore.addAssets([assetOne]); assetStore.addAssets([assetTwo]); @@ -194,15 +220,21 @@ describe('AssetStore', () => { }); it('orders assets in buckets by descending date', () => { - const assetOne = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); - const assetTwo = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'), - }); - const assetThree = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-16T12:00:00.000Z'), - }); + const assetOne = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + const assetTwo = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'), + }), + ); + const assetThree = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'), + }), + ); assetStore.addAssets([assetOne, assetTwo, assetThree]); const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 }); @@ -214,15 +246,21 @@ describe('AssetStore', () => { }); it('orders buckets by descending date', () => { - const assetOne = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); - const assetTwo = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-04-20T12:00:00.000Z'), - }); - const assetThree = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2023-01-20T12:00:00.000Z'), - }); + const assetOne = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + const assetTwo = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'), + }), + ); + const assetThree = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'), + }), + ); assetStore.addAssets([assetOne, assetTwo, assetThree]); expect(assetStore.buckets.length).toEqual(3); @@ -238,7 +276,7 @@ describe('AssetStore', () => { it('updates existing asset', () => { const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets'); - const asset = timelineAssetFactory.build(); + const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); assetStore.addAssets([asset]); assetStore.addAssets([asset]); @@ -248,8 +286,8 @@ describe('AssetStore', () => { // disabled due to the wasm Justified Layout import it('ignores trashed assets when isTrashed is true', async () => { - const asset = timelineAssetFactory.build({ isTrashed: false }); - const trashedAsset = timelineAssetFactory.build({ isTrashed: true }); + const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false })); + const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true })); const assetStore = new AssetStore(); await assetStore.updateOptions({ isTrashed: true }); @@ -269,14 +307,14 @@ describe('AssetStore', () => { }); it('ignores non-existing assets', () => { - assetStore.updateAssets([timelineAssetFactory.build()]); + assetStore.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]); expect(assetStore.buckets.length).toEqual(0); expect(assetStore.count).toEqual(0); }); it('updates an asset', () => { - const asset = timelineAssetFactory.build({ isFavorite: false }); + const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false })); const updatedAsset = { ...asset, isFavorite: true }; assetStore.addAssets([asset]); @@ -289,10 +327,15 @@ describe('AssetStore', () => { }); it('asset moves buckets when asset date changes', () => { - const asset = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'), }); - const updatedAsset = { ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-20T12:00:00.000Z') }; assetStore.addAssets([asset]); expect(assetStore.buckets.length).toEqual(1); @@ -320,7 +363,11 @@ describe('AssetStore', () => { it('ignores invalid IDs', () => { assetStore.addAssets( - timelineAssetFactory.buildList(2, { localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z') }), + timelineAssetFactory + .buildList(2, { + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }) + .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)), ); assetStore.removeAssets(['', 'invalid', '4c7d9acc']); @@ -330,9 +377,11 @@ describe('AssetStore', () => { }); it('removes asset from bucket', () => { - const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); + const [assetOne, assetTwo] = timelineAssetFactory + .buildList(2, { + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }) + .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); assetStore.addAssets([assetOne, assetTwo]); assetStore.removeAssets([assetOne.id]); @@ -342,9 +391,11 @@ describe('AssetStore', () => { }); it('does not remove bucket when empty', () => { - const assets = timelineAssetFactory.buildList(2, { - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); + const assets = timelineAssetFactory + .buildList(2, { + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }) + .map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)); assetStore.addAssets(assets); assetStore.removeAssets(assets.map((asset) => asset.id)); @@ -367,12 +418,16 @@ describe('AssetStore', () => { }); it('populated store returns first asset', () => { - const assetOne = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); - const assetTwo = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'), - }); + const assetOne = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + const assetTwo = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'), + }), + ); assetStore.addAssets([assetOne, assetTwo]); expect(assetStore.getFirstAsset()).toEqual(assetOne); }); @@ -381,15 +436,24 @@ describe('AssetStore', () => { describe('getLaterAsset', () => { let assetStore: AssetStore; const bucketAssets: Record = { - '2024-03-01T00:00:00.000Z': timelineAssetFactory - .buildList(1) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })), - '2024-02-01T00:00:00.000Z': timelineAssetFactory - .buildList(6) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })), - '2024-01-01T00:00:00.000Z': timelineAssetFactory - .buildList(3) - .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })), + '2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'), + }), + ), + '2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'), + }), + ), + '2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) => + deriveLocalDateTimeFromFileCreatedAt({ + ...asset, + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'), + }), + ), }; const bucketAssetsResponse: Record = Object.fromEntries( Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), @@ -479,12 +543,16 @@ describe('AssetStore', () => { }); it('returns the bucket index', () => { - const assetOne = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); - const assetTwo = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'), - }); + const assetOne = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + const assetTwo = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'), + }), + ); assetStore.addAssets([assetOne, assetTwo]); expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024); @@ -494,12 +562,16 @@ describe('AssetStore', () => { }); it('ignores removed buckets', () => { - const assetOne = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), - }); - const assetTwo = timelineAssetFactory.build({ - localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'), - }); + const assetOne = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + const assetTwo = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'), + }), + ); assetStore.addAssets([assetOne, assetTwo]); assetStore.removeAssets([assetTwo.id]); diff --git a/web/src/lib/utils/thumbnail-util.spec.ts b/web/src/lib/utils/thumbnail-util.spec.ts index 9e8a8ee66d..121c50512d 100644 --- a/web/src/lib/utils/thumbnail-util.spec.ts +++ b/web/src/lib/utils/thumbnail-util.spec.ts @@ -62,6 +62,15 @@ describe('getAltText', () => { ownerId: 'test-owner', ratio: 1, thumbhash: null, + fileCreatedAt: { + year: testDate.getUTCFullYear(), + month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based + day: testDate.getUTCDate(), + hour: testDate.getUTCHours(), + minute: testDate.getUTCMinutes(), + second: testDate.getUTCSeconds(), + millisecond: testDate.getUTCMilliseconds(), + }, localDateTime: { year: testDate.getUTCFullYear(), month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based @@ -71,6 +80,7 @@ describe('getAltText', () => { second: testDate.getUTCSeconds(), millisecond: testDate.getUTCMilliseconds(), }, + visibility: AssetVisibility.Timeline, isFavorite: false, isTrashed: false, diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index 89cbf3a6a8..0e53b2d79e 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -46,16 +46,16 @@ export const getAltText = derived(t, ($t) => { }); const hasPlace = asset.city && asset.country; - const peopleCount = asset.people.length; + const peopleCount = asset.people?.length ?? 0; const isVideo = asset.isVideo; const values = { date, city: asset.city, country: asset.country, - person1: asset.people[0], - person2: asset.people[1], - person3: asset.people[2], + person1: asset.people?.[0], + person2: asset.people?.[1], + person3: asset.people?.[2], isVideo, additionalCount: peopleCount > 3 ? peopleCount - 2 : 0, }; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 6829bc67f9..6cf84f577e 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -5,17 +5,81 @@ import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { DateTime, type LocaleOptions } from 'luxon'; import { get } from 'svelte/store'; +// Move type definitions to the top +export type TimelinePlainYearMonth = { + year: number; + month: number; +}; + +export type TimelinePlainDate = TimelinePlainYearMonth & { + day: number; +}; + +export type TimelinePlainDateTime = TimelinePlainDate & { + hour: number; + minute: number; + second: number; + millisecond: number; +}; + export type ScrubberListener = ( bucketDate: { year: number; month: number }, overallScrollPercent: number, bucketScrollPercent: number, ) => void | Promise; -export const fromLocalDateTime = (localDateTime: string) => - DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); +// used for AssetResponseDto.dateTimeOriginal, amongst others +export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime => + DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime; -export const fromLocalDateTimeToObject = (localDateTime: string): TimelinePlainDateTime => - (fromLocalDateTime(localDateTime) as DateTime).toObject(); +export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelinePlainDateTime => + (fromISODateTime(isoDateTime, timeZone) as DateTime).toObject(); + +// used for AssetResponseDto.localDateTime, amongst others +export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC'); + +export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelinePlainDateTime => + (fromISODateTimeUTC(isoDateTimeUtc) as DateTime).toObject(); + +// used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information +export const fromISODateTimeTruncateTZToObject = ( + isoDateTimeUtc: string, + timeZone: string | undefined, +): TimelinePlainDateTime => + ( + fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime + ).toObject(); + +// Used to derive a local date time from an ISO string and a UTC offset in hours +export const fromISODateTimeWithOffsetToObject = ( + isoDateTimeUtc: string, + utcOffsetHours: number, +): TimelinePlainDateTime => { + const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc); + + // Apply the offset to get the local time + // Note: offset is in hours (may be fractional), positive for east of UTC, negative for west + const localDateTime = utcDateTime.plus({ hours: utcOffsetHours }); + + // Return as plain object (keeping the local time but in UTC zone context) + return (localDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime).toObject(); +}; + +export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => { + const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc); + const fileCreatedAt = (utcDateTime as DateTime).toObject(); + + // Apply the offset to get the local time + // Note: offset is in hours (may be fractional), positive for east of UTC, negative for west + const luxonLocalDateTime = utcDateTime.plus({ hours: localUtcOffsetHours }); + // Return as plain object (keeping the local time but in UTC zone context) + const localDateTime = (luxonLocalDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime).toObject(); + + return { + fileCreatedAt, + localDateTime, + }; +}; export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime => DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime; @@ -32,10 +96,7 @@ export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearM { zone: 'local', locale: get(locale) }, ) as DateTime; -export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => - DateTime.fromISO(dateTimeOriginal, { zone: timeZone, locale: get(locale) }); - -export const toISOLocalDateTime = (timelineYearMonth: TimelinePlainYearMonth): string => +export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string => (fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime).toISO(); export function formatBucketTitle(_date: DateTime): string { @@ -104,12 +165,16 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): const country = assetResponse.exifInfo?.country; const people = assetResponse.people?.map((person) => person.name) || []; + const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime); + const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC'); + return { id: assetResponse.id, ownerId: assetResponse.ownerId, ratio, thumbhash: assetResponse.thumbhash, - localDateTime: fromLocalDateTimeToObject(assetResponse.localDateTime), + localDateTime, + fileCreatedAt, isFavorite: assetResponse.isFavorite, visibility: assetResponse.visibility, isTrashed: assetResponse.isTrashed, @@ -151,19 +216,3 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTim } return aDateTime.millisecond - bDateTime.millisecond; }; - -export type TimelinePlainDateTime = TimelinePlainDate & { - hour: number; - minute: number; - second: number; - millisecond: number; -}; - -export type TimelinePlainDate = TimelinePlainYearMonth & { - day: number; -}; - -export type TimelinePlainYearMonth = { - year: number; - month: number; -}; diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 1955e79b72..c2f03f9c6a 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -1,5 +1,5 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { fromLocalDateTimeToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util'; +import { fromISODateTimeUTCToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util'; import { faker } from '@faker-js/faker'; import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; @@ -34,7 +34,8 @@ export const timelineAssetFactory = Sync.makeFactory({ ratio: Sync.each(() => faker.number.int()), ownerId: Sync.each(() => faker.string.uuid()), thumbhash: Sync.each(() => faker.string.alphanumeric(28)), - localDateTime: Sync.each(() => fromLocalDateTimeToObject(faker.date.past().toISOString())), + localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), + fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), isFavorite: Sync.each(() => faker.datatype.boolean()), visibility: AssetVisibility.Timeline, isTrashed: false, @@ -60,7 +61,8 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { isImage: [], isTrashed: [], livePhotoVideoId: [], - localDateTime: [], + fileCreatedAt: [], + localOffsetHours: [], ownerId: [], projectionType: [], ratio: [], @@ -68,6 +70,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { thumbhash: [], }; for (const asset of timelineAsset) { + const fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt).toISO(); bucketAssets.city.push(asset.city); bucketAssets.country.push(asset.country); bucketAssets.duration.push(asset.duration!); @@ -77,7 +80,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { bucketAssets.isImage.push(asset.isImage); bucketAssets.isTrashed.push(asset.isTrashed); bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!); - bucketAssets.localDateTime.push(fromTimelinePlainDateTime(asset.localDateTime).toISO()); + bucketAssets.fileCreatedAt.push(fileCreatedAt); bucketAssets.ownerId.push(asset.ownerId); bucketAssets.projectionType.push(asset.projectionType!); bucketAssets.ratio.push(asset.ratio); diff --git a/web/src/test-data/factories/preferences-factory.ts b/web/src/test-data/factories/preferences-factory.ts index d531bc1a99..e7d556b00b 100644 --- a/web/src/test-data/factories/preferences-factory.ts +++ b/web/src/test-data/factories/preferences-factory.ts @@ -1,7 +1,10 @@ -import type { UserPreferencesResponseDto } from '@immich/sdk'; +import { AssetOrder, type UserPreferencesResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const preferencesFactory = Sync.makeFactory({ + albums: { + defaultAssetOrder: AssetOrder.Desc, + }, cast: { gCastEnabled: false, },