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/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 58c3ef8394..c3bfe5a978 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; } } 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/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/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 09c0143e80..294517af42 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 b390bf7477..722239418d 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 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/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/validation.ts b/server/src/validation.ts index 2d160f43ce..bacf4b6f5a 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -6,7 +6,7 @@ import { ParseUUIDPipe, applyDecorators, } from '@nestjs/common'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, @@ -72,22 +72,28 @@ export class UUIDParamDto { } type PinCodeOptions = { optional?: boolean } & OptionalOptions; -export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => { +export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => { + const { optional, nullable, emptyToNull, ...apiPropertyOptions } = { + optional: false, + nullable: false, + emptyToNull: false, + ...options, + }; const decorators = [ IsString(), IsNotEmpty(), Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }), - ApiProperty({ example: '123456' }), + ApiProperty({ example: '123456', ...apiPropertyOptions }), ]; if (optional) { - decorators.push(Optional(options)); + decorators.push(Optional({ nullable, emptyToNull })); } return applyDecorators(...decorators); }; -export interface OptionalOptions extends ValidationOptions { +export interface OptionalOptions { nullable?: boolean; /** convert empty strings to null */ emptyToNull?: boolean; @@ -127,22 +133,32 @@ export const ValidateHexColor = () => { }; type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; -export const ValidateUUID = (options?: UUIDOptions) => { - const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options }; +export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { + const { optional, each, nullable, ...apiPropertyOptions } = { + optional: false, + each: false, + nullable: false, + ...options, + }; return applyDecorators( IsUUID('4', { each }), - ApiProperty({ format: 'uuid' }), + ApiProperty({ format: 'uuid', ...apiPropertyOptions }), optional ? Optional({ nullable }) : IsNotEmpty(), each ? IsArray() : IsString(), ); }; type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; -export const ValidateDate = (options?: DateOptions) => { - const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; +export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => { + const { optional, nullable, format, ...apiPropertyOptions } = { + optional: false, + nullable: false, + format: 'date-time', + ...options, + }; const decorators = [ - ApiProperty({ format }), + ApiProperty({ format, ...apiPropertyOptions }), IsDate(), optional ? Optional({ nullable: true }) : IsNotEmpty(), Transform(({ key, value }) => { @@ -166,9 +182,12 @@ export const ValidateDate = (options?: DateOptions) => { }; type AssetVisibilityOptions = { optional?: boolean }; -export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { - const { optional } = { optional: false, ...options }; - const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })]; +export const ValidateAssetVisibility = (options?: AssetVisibilityOptions & ApiPropertyOptions) => { + const { optional, ...apiPropertyOptions } = { optional: false, ...options }; + const decorators = [ + IsEnum(AssetVisibility), + ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility, ...apiPropertyOptions }), + ]; if (optional) { decorators.push(Optional()); @@ -177,10 +196,10 @@ export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { }; type BooleanOptions = { optional?: boolean }; -export const ValidateBoolean = (options?: BooleanOptions) => { - const { optional } = { optional: false, ...options }; +export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => { + const { optional, ...apiPropertyOptions } = { optional: false, ...options }; const decorators = [ - // ApiProperty(), + ApiProperty(apiPropertyOptions), IsBoolean(), Transform(({ value }) => { if (value == 'true') { diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index de8e355d33..c8adabd055 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -18,7 +18,7 @@ import { getByteUnitString } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; - import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util'; + import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo, @@ -112,8 +112,8 @@ let timeZone = $derived(asset.exifInfo?.timeZone); let dateTime = $derived( timeZone && asset.exifInfo?.dateTimeOriginal - ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone) - : fromLocalDateTime(asset.localDateTime), + ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) + : fromISODateTimeUTC(asset.localDateTime), ); const getMegapixel = (width: number, height: number): number | undefined => { diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 62e74f5685..b36082b500 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -35,7 +35,7 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util'; + import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, getAssetInfo } from '@immich/sdk'; import { IconButton } from '@immich/ui'; import { @@ -576,7 +576,7 @@

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

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