diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 35b98a35f3..c6fb8e40d2 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -116,7 +116,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:ff21bc0f8194dc9c105b769aeabf9585fea6a8ed649c0781caeac5cb3c247884 + image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 412d9cccdd..abcf3af728 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:ff21bc0f8194dc9c105b769aeabf9585fea6a8ed649c0781caeac5cb3c247884 + image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aec55fe920..3536879145 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:8-bookworm@sha256:ff21bc0f8194dc9c105b769aeabf9585fea6a8ed649c0781caeac5cb3c247884 + image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 534d8e95d0..0db89fbfe7 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -13,6 +13,9 @@ import { mdiTrashCan, mdiWeb, mdiWrap, + mdiCloudKeyOutline, + mdiRegex, + mdiCodeJson, } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; @@ -23,6 +26,30 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiRegex, + iconColor: 'purple', + title: 'Zitadel Actions are cursed', + description: + "Zitadel is cursed because its custom scripting feature is executed with a JS engine that doesn't support regex named capture groups.", + link: { + url: 'https://github.com/dop251/goja', + text: 'Go JS engine', + }, + date: new Date(2025, 5, 4), + }, + { + icon: mdiCloudKeyOutline, + iconColor: '#0078d4', + title: 'Entra is cursed', + description: + "Microsoft Entra supports PKCE, but doesn't include it in its OpenID discovery document. This leads to clients thinking PKCE isn't available.", + link: { + url: 'https://github.com/immich-app/immich/pull/18725', + text: '#18725', + }, + date: new Date(2025, 4, 30), + }, { icon: mdiCrop, iconColor: 'tomato', @@ -35,6 +62,17 @@ const items: Item[] = [ }, date: new Date(2025, 4, 5), }, + { + icon: mdiCodeJson, + iconColor: 'yellow', + title: 'YAML whitespace is cursed', + description: 'YAML whitespaces are often handled in unintuitive ways.', + link: { + url: 'https://github.com/immich-app/immich/pull/17309', + text: '#17309', + }, + date: new Date(2025, 3, 1), + }, { icon: mdiMicrosoftWindows, iconColor: '#357EC7', diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 29a17c795b..5188a2d017 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -28,8 +28,10 @@ services: extra_hosts: - 'auth-server:host-gateway' depends_on: - - redis - - database + redis: + condition: service_started + database: + condition: service_healthy ports: - 2285:2285 @@ -37,7 +39,7 @@ services: image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:e6d1209c1c13791c6f9fbf726c41865e3320dfe2445a6b4ffb03e25f904b3b37 + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:9c704fb49ce27549df00f1b096cc93f8b0c959ef087507704d74954808f78a82 command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf environment: POSTGRES_PASSWORD: postgres @@ -45,3 +47,9 @@ services: POSTGRES_DB: immich ports: - 5435:5432 + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] + interval: 1s + timeout: 5s + retries: 30 + start_period: 10s 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..2fbce6fbb7 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", @@ -460,6 +463,7 @@ "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", "assets_added_to_name_count": "Added {count, plural, one {# asset} other {# assets}} to {hasName, select, true {{name}} other {new album}}", + "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album", "assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", 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 4cf4690493..9c81460dfc 100644 --- a/mobile/lib/domain/interfaces/local_album.interface.dart +++ b/mobile/lib/domain/interfaces/local_album.interface.dart @@ -30,6 +30,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 355487b193..5b46fd5d0d 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)); + }); } @override 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 32bb025916..4695540424 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/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index f22fc30716..86b23fba30 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -114,9 +114,9 @@ class AlbumViewer extends HookConsumerWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ + context.primaryColor.withValues(alpha: 0.06), context.primaryColor.withValues(alpha: 0.04), - context.primaryColor.withValues(alpha: 0.02), - Colors.orange.withValues(alpha: 0.02), + Colors.indigo.withValues(alpha: 0.02), Colors.transparent, ], stops: const [0.0, 0.3, 0.7, 1.0], diff --git a/mobile/lib/pages/backup/exp_backup_controller.page.dart b/mobile/lib/pages/backup/exp_backup_controller.page.dart index e058d8ef05..3848ea3cd1 100644 --- a/mobile/lib/pages/backup/exp_backup_controller.page.dart +++ b/mobile/lib/pages/backup/exp_backup_controller.page.dart @@ -193,7 +193,7 @@ class ExpBackupPage extends HookConsumerWidget { ), trailing: ElevatedButton( onPressed: () async { - await context.pushRoute(const BackupAlbumSelectionRoute()); + await context.pushRoute(const ExpBackupAlbumSelectionRoute()); // waited until returning from selection await ref .read(backupProvider.notifier) 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/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart index 52ad885735..7a6d3929a5 100644 --- a/mobile/lib/providers/backup/backup_album.provider.dart +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -19,6 +19,7 @@ class BackupAlbumNotifier extends StateNotifier> { Future getAll() async { state = await _localAlbumService.getAll(); + print("Backup albums loaded: ${state.length}"); } Future selectAlbum(LocalAlbum album) async { 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 58c3ef8394..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': @@ -42,6 +43,11 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); } break; + case 'LoginResponseDto': + if (value is Map) { + addDefault(value, 'isOnboarded', false); + } + break; } } diff --git a/mobile/lib/widgets/album/album_action_filled_button.dart b/mobile/lib/widgets/album/album_action_filled_button.dart index f5064f499c..48a8a27f59 100644 --- a/mobile/lib/widgets/album/album_action_filled_button.dart +++ b/mobile/lib/widgets/album/album_action_filled_button.dart @@ -17,11 +17,15 @@ class AlbumActionFilledButton extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 8.0), - child: FilledButton.icon( - style: FilledButton.styleFrom( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + side: BorderSide( + color: context.colorScheme.surfaceContainerHighest, + width: 1, ), backgroundColor: context.colorScheme.surfaceContainerHigh, ), diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 9d48045459..14715e40a9 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -326,6 +326,7 @@ class AlbumViewerAppbar extends HookConsumerWidget return AppBar( elevation: 0, + backgroundColor: context.scaffoldBackgroundColor, leading: buildLeadingButton(), centerTitle: false, actions: [ diff --git a/mobile/lib/widgets/album/album_viewer_editable_description.dart b/mobile/lib/widgets/album/album_viewer_editable_description.dart index 06bfbc0186..b82e7f3d83 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_description.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_description.dart @@ -55,7 +55,7 @@ class AlbumViewerEditableDescription extends HookConsumerWidget { } }, focusNode: descriptionFocusNode, - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyLarge, maxLines: 3, minLines: 1, controller: descriptionTextEditController, 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/schema/migrations/1749067526135-UserOnboardingDefault.ts b/server/src/schema/migrations/1749067526135-UserOnboardingDefault.ts new file mode 100644 index 0000000000..376541410f --- /dev/null +++ b/server/src/schema/migrations/1749067526135-UserOnboardingDefault.ts @@ -0,0 +1,12 @@ +import { Kysely, sql } from 'kysely'; +import { UserMetadataKey } from 'src/enum'; + +export async function up(db: Kysely): Promise { + await sql`INSERT INTO user_metadata SELECT id, ${UserMetadataKey.ONBOARDING}, '{"isOnboarded": true}' FROM users + ON CONFLICT ("userId", key) DO NOTHING + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DELETE FROM user_metadata WHERE key = ${UserMetadataKey.ONBOARDING}`.execute(db); +} 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/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 59d4b5e6ea..71cd9de932 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -6,7 +6,7 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { AssetStore } from '$lib/stores/assets-store.svelte'; + import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 0918c86bfe..fd37d253aa 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -1,5 +1,5 @@ import type { AssetAction } from '$lib/constants'; -import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; +import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { AlbumResponseDto } from '@immich/sdk'; type ActionMap = { diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte index 89bf5b72cb..9a2e1a9553 100644 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/download-action.svelte @@ -2,7 +2,7 @@ import { shortcut } from '$lib/actions/shortcut'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { downloadFile } from '$lib/utils/asset-utils'; import { getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte index e36c173b1d..440cdeb2a6 100644 --- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte @@ -12,8 +12,9 @@ onClick(!isPlaying)} diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte index ab97427524..1b06bf8e21 100644 --- a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte @@ -3,7 +3,7 @@ import { AssetAction } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; - import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { handleError } from '$lib/utils/handle-error'; import { AssetVisibility, updateAssets } from '@immich/sdk'; import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js'; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index eadecfff95..e5d2554e2c 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,7 +10,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { isShowDetail } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; 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/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 3dffc0e84c..9cb80fb2dd 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -4,7 +4,8 @@ import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; - import { photoViewerImgElement, type TimelineAsset } from '$lib/stores/assets-store.svelte'; + import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 1f9a29268f..00fef98c09 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -18,7 +18,7 @@ import { thumbhash } from '$lib/actions/thumbhash'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { moveFocus } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; @@ -231,7 +231,7 @@ {#if (!loaded || thumbError) && asset.thumbhash}

- {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/photos-page/actions/focus-actions.ts b/web/src/lib/components/photos-page/actions/focus-actions.ts index 5085faa0a3..3a75820cd8 100644 --- a/web/src/lib/components/photos-page/actions/focus-actions.ts +++ b/web/src/lib/components/photos-page/actions/focus-actions.ts @@ -1,4 +1,5 @@ -import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte'; +import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte'; +import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { moveFocus } from '$lib/utils/focus-util'; import { InvocationTracker } from '$lib/utils/invocationTracker'; import { tick } from 'svelte'; diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte index 1d36c79730..09ca94cb25 100644 --- a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte +++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte @@ -1,6 +1,6 @@