From 81423420c89be2980573fb9ac6a5573c2d615472 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 5 Jun 2025 22:53:45 +0530 Subject: [PATCH 1/2] chore(mobile): patch isOnboarded (#18949) fix: patch isOnboarded Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/utils/openapi_patching.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 58c3ef83947..c3bfe5a9785 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -42,6 +42,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; } } From 55f4e93456ab7415082e342af9ad5cabd52562a6 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 5 Jun 2025 21:56:32 -0400 Subject: [PATCH 2/2] fix: regression: sort day by fileCreatedAt again (#18732) * fix: regression: sort day by fileCreatedAt again * lint * e2e test * inline function * e2e * Address comments. Drop dayGroup and timezone in favor of localOffsetMinutes * lint and some api-doc * lint, more api-doc * format * Move minutes to fractional hours * make sql * merge/conflict * merge fallout, review comments * spelling * drop offset from returned date * move description into decorator where possible, regen api --- e2e/src/api/specs/timeline.e2e-spec.ts | 10 +- mobile/openapi/lib/api/timeline_api.dart | 42 +++ .../openapi/lib/model/asset_response_dto.dart | 4 + .../model/time_bucket_asset_response_dto.dart | 45 +++- .../lib/model/time_buckets_response_dto.dart | 2 + open-api/immich-openapi-specs.json | 64 ++++- open-api/typescript-sdk/src/fetch-client.ts | 26 +- server/src/dtos/asset-response.dto.ts | 28 ++ server/src/dtos/time-bucket.dto.ts | 149 +++++++++-- server/src/queries/asset.repository.sql | 16 +- server/src/repositories/asset.repository.ts | 93 ++++--- server/src/validation.ts | 53 ++-- .../asset-viewer/detail-panel.svelte | 6 +- .../memory-page/memory-viewer.svelte | 4 +- .../timeline-manager/asset-bucket.svelte.ts | 13 +- .../asset-date-group.svelte.ts | 2 +- .../timeline-manager/asset-store.svelte.ts | 4 +- .../lib/managers/timeline-manager/types.ts | 3 +- web/src/lib/stores/assets-store.spec.ts | 242 ++++++++++++------ web/src/lib/utils/thumbnail-util.spec.ts | 10 + web/src/lib/utils/thumbnail-util.ts | 8 +- web/src/lib/utils/timeline-util.ts | 99 +++++-- web/src/test-data/factories/asset-factory.ts | 11 +- 23 files changed, 687 insertions(+), 247 deletions(-) diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index 5db184bf76d..e633c8694de 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/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 33914d5b472..042bc704019 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/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 3d85b779ccf..e2f60937f8e 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 3f1406c019d..886b353f68b 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 8c9f8dab611..11faa815e27 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/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 09c0143e805..294517af42e 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" } @@ -9369,10 +9391,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 +9431,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 +9494,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 +14454,7 @@ "TimeBucketAssetResponseDto": { "properties": { "city": { + "description": "Array of city names extracted from EXIF GPS data", "items": { "nullable": true, "type": "string" @@ -14431,6 +14462,7 @@ "type": "array" }, "country": { + "description": "Array of country names extracted from EXIF GPS data", "items": { "nullable": true, "type": "string" @@ -14438,56 +14470,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 +14543,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 +14563,7 @@ "type": "array" }, "thumbhash": { + "description": "Array of BlurHash strings for generating asset previews (base64 encoded)", "items": { "nullable": true, "type": "string" @@ -14521,6 +14571,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 +14582,13 @@ "city", "country", "duration", + "fileCreatedAt", "id", "isFavorite", "isImage", "isTrashed", "livePhotoVideoId", - "localDateTime", + "localOffsetHours", "ownerId", "projectionType", "ratio", @@ -14548,9 +14600,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" } }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b390bf74779..722239418dc 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -312,7 +312,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 +325,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 +340,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 +1446,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 = { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 9bbfb450b28..1e214c38600 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 3f4157babb9..af2eae7e726 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/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index d85ad341d00..b6c5d4bea88 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 416cf4e5de9..af5239ed708 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/validation.ts b/server/src/validation.ts index 2d160f43ce9..bacf4b6f5a1 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -6,7 +6,7 @@ import { ParseUUIDPipe, applyDecorators, } from '@nestjs/common'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, @@ -72,22 +72,28 @@ export class UUIDParamDto { } type PinCodeOptions = { optional?: boolean } & OptionalOptions; -export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => { +export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => { + const { optional, nullable, emptyToNull, ...apiPropertyOptions } = { + optional: false, + nullable: false, + emptyToNull: false, + ...options, + }; const decorators = [ IsString(), IsNotEmpty(), Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }), - ApiProperty({ example: '123456' }), + ApiProperty({ example: '123456', ...apiPropertyOptions }), ]; if (optional) { - decorators.push(Optional(options)); + decorators.push(Optional({ nullable, emptyToNull })); } return applyDecorators(...decorators); }; -export interface OptionalOptions extends ValidationOptions { +export interface OptionalOptions { nullable?: boolean; /** convert empty strings to null */ emptyToNull?: boolean; @@ -127,22 +133,32 @@ export const ValidateHexColor = () => { }; type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; -export const ValidateUUID = (options?: UUIDOptions) => { - const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options }; +export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { + const { optional, each, nullable, ...apiPropertyOptions } = { + optional: false, + each: false, + nullable: false, + ...options, + }; return applyDecorators( IsUUID('4', { each }), - ApiProperty({ format: 'uuid' }), + ApiProperty({ format: 'uuid', ...apiPropertyOptions }), optional ? Optional({ nullable }) : IsNotEmpty(), each ? IsArray() : IsString(), ); }; type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; -export const ValidateDate = (options?: DateOptions) => { - const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; +export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { + const { optional, nullable, format, ...apiPropertyOptions } = { + optional: false, + nullable: false, + format: 'date-time', + ...options, + }; const decorators = [ - ApiProperty({ format }), + ApiProperty({ format, ...apiPropertyOptions }), IsDate(), optional ? Optional({ nullable: true }) : IsNotEmpty(), Transform(({ key, value }) => { @@ -166,9 +182,12 @@ export const ValidateDate = (options?: DateOptions) => { }; type AssetVisibilityOptions = { optional?: boolean }; -export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { - const { optional } = { optional: false, ...options }; - const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })]; +export const ValidateAssetVisibility = (options?: AssetVisibilityOptions & ApiPropertyOptions) => { + const { optional, ...apiPropertyOptions } = { optional: false, ...options }; + const decorators = [ + IsEnum(AssetVisibility), + ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility, ...apiPropertyOptions }), + ]; if (optional) { decorators.push(Optional()); @@ -177,10 +196,10 @@ export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { }; type BooleanOptions = { optional?: boolean }; -export const ValidateBoolean = (options?: BooleanOptions) => { - const { optional } = { optional: false, ...options }; +export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => { + const { optional, ...apiPropertyOptions } = { optional: false, ...options }; const decorators = [ - // ApiProperty(), + ApiProperty(apiPropertyOptions), IsBoolean(), Transform(({ value }) => { if (value == 'true') { diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index de8e355d33b..c8adabd055e 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -18,7 +18,7 @@ import { getByteUnitString } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; - import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util'; + import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo, @@ -112,8 +112,8 @@ let timeZone = $derived(asset.exifInfo?.timeZone); let dateTime = $derived( timeZone && asset.exifInfo?.dateTimeOriginal - ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone) - : fromLocalDateTime(asset.localDateTime), + ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) + : fromISODateTimeUTC(asset.localDateTime), ); const getMegapixel = (width: number, height: number): number | undefined => { diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 62e74f5685b..b36082b5009 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -35,7 +35,7 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util'; + import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; import { @@ -576,7 +576,7 @@

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

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