Merge branch 'main' into service_worker_appstatic

This commit is contained in:
Alex 2025-06-05 20:57:45 -05:00 committed by GitHub
commit 9f532e926e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 692 additions and 247 deletions

View File

@ -75,8 +75,8 @@ describe('/timeline', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, { count: 3, timeBucket: '1970-02-01' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, { count: 1, timeBucket: '1970-01-01' },
]), ]),
); );
}); });
@ -167,7 +167,8 @@ describe('/timeline', () => {
isImage: [], isImage: [],
isTrashed: [], isTrashed: [],
livePhotoVideoId: [], livePhotoVideoId: [],
localDateTime: [], fileCreatedAt: [],
localOffsetHours: [],
ownerId: [], ownerId: [],
projectionType: [], projectionType: [],
ratio: [], ratio: [],
@ -204,7 +205,8 @@ describe('/timeline', () => {
isImage: [], isImage: [],
isTrashed: [], isTrashed: [],
livePhotoVideoId: [], livePhotoVideoId: [],
localDateTime: [], fileCreatedAt: [],
localOffsetHours: [],
ownerId: [], ownerId: [],
projectionType: [], projectionType: [],
ratio: [], ratio: [],

View File

@ -42,6 +42,11 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
} }
break; break;
case 'LoginResponseDto':
if (value is Map) {
addDefault(value, 'isOnboarded', false);
}
break;
} }
} }

View File

@ -20,28 +20,39 @@ class TimelineApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] timeBucket (required): /// * [String] timeBucket (required):
/// Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)
/// ///
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album
/// ///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
/// * [bool] isTrashed: /// * [bool] isTrashed:
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
/// ///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition)
/// ///
/// * [String] tagId: /// * [String] tagId:
/// Filter assets with a specific tag
/// ///
/// * [String] userId: /// * [String] userId:
/// Filter assets by specific user ID
/// ///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> 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 { Future<Response> 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 // ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket'; final apiPath = r'/timeline/bucket';
@ -105,28 +116,39 @@ class TimelineApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] timeBucket (required): /// * [String] timeBucket (required):
/// Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)
/// ///
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album
/// ///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
/// * [bool] isTrashed: /// * [bool] isTrashed:
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
/// ///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition)
/// ///
/// * [String] tagId: /// * [String] tagId:
/// Filter assets with a specific tag
/// ///
/// * [String] userId: /// * [String] userId:
/// Filter assets by specific user ID
/// ///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<TimeBucketAssetResponseDto?> 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 { Future<TimeBucketAssetResponseDto?> 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, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
@ -146,26 +168,36 @@ class TimelineApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album
/// ///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
/// * [bool] isTrashed: /// * [bool] isTrashed:
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
/// ///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition)
/// ///
/// * [String] tagId: /// * [String] tagId:
/// Filter assets with a specific tag
/// ///
/// * [String] userId: /// * [String] userId:
/// Filter assets by specific user ID
/// ///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { Future<Response> 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 // ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets'; final apiPath = r'/timeline/buckets';
@ -228,26 +260,36 @@ class TimelineApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album
/// ///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
/// * [bool] isTrashed: /// * [bool] isTrashed:
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
/// ///
/// * [String] key: /// * [String] key:
/// ///
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
/// ///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition)
/// ///
/// * [String] tagId: /// * [String] tagId:
/// Filter assets with a specific tag
/// ///
/// * [String] userId: /// * [String] userId:
/// Filter assets by specific user ID
/// ///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
/// ///
/// * [bool] withPartners: /// * [bool] withPartners:
/// Include assets shared by partners
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { Future<List<TimeBucketsResponseDto>?> 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, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {

View File

@ -65,8 +65,10 @@ class AssetResponseDto {
/// ///
ExifResponseDto? exifInfo; 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; 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; DateTime fileModifiedAt;
bool hasMetadata; bool hasMetadata;
@ -86,6 +88,7 @@ class AssetResponseDto {
String? livePhotoVideoId; 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; DateTime localDateTime;
String originalFileName; String originalFileName;
@ -131,6 +134,7 @@ class AssetResponseDto {
List<AssetFaceWithoutPersonResponseDto> unassignedFaces; List<AssetFaceWithoutPersonResponseDto> 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; DateTime updatedAt;
AssetVisibility visibility; AssetVisibility visibility;

View File

@ -16,12 +16,13 @@ class TimeBucketAssetResponseDto {
this.city = const [], this.city = const [],
this.country = const [], this.country = const [],
this.duration = const [], this.duration = const [],
this.fileCreatedAt = const [],
this.id = const [], this.id = const [],
this.isFavorite = const [], this.isFavorite = const [],
this.isImage = const [], this.isImage = const [],
this.isTrashed = const [], this.isTrashed = const [],
this.livePhotoVideoId = const [], this.livePhotoVideoId = const [],
this.localDateTime = const [], this.localOffsetHours = const [],
this.ownerId = const [], this.ownerId = const [],
this.projectionType = const [], this.projectionType = const [],
this.ratio = const [], this.ratio = const [],
@ -30,35 +31,52 @@ class TimeBucketAssetResponseDto {
this.visibility = const [], this.visibility = const [],
}); });
/// Array of city names extracted from EXIF GPS data
List<String?> city; List<String?> city;
/// Array of country names extracted from EXIF GPS data
List<String?> country; List<String?> country;
/// Array of video durations in HH:MM:SS format (null for images)
List<String?> duration; List<String?> duration;
/// Array of file creation timestamps in UTC (ISO 8601 format, without timezone)
List<String> fileCreatedAt;
/// Array of asset IDs in the time bucket
List<String> id; List<String> id;
/// Array indicating whether each asset is favorited
List<bool> isFavorite; List<bool> isFavorite;
/// Array indicating whether each asset is an image (false for videos)
List<bool> isImage; List<bool> isImage;
/// Array indicating whether each asset is in the trash
List<bool> isTrashed; List<bool> isTrashed;
/// Array of live photo video asset IDs (null for non-live photos)
List<String?> livePhotoVideoId; List<String?> livePhotoVideoId;
List<String> 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<num> localOffsetHours;
/// Array of owner IDs for each asset
List<String> ownerId; List<String> ownerId;
/// Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\")
List<String?> projectionType; List<String?> projectionType;
/// Array of aspect ratios (width/height) for each asset
List<num> ratio; List<num> ratio;
/// (stack ID, stack asset count) tuple /// Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)
List<List<String>?> stack; List<List<String>?> stack;
/// Array of BlurHash strings for generating asset previews (base64 encoded)
List<String?> thumbhash; List<String?> thumbhash;
/// Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)
List<AssetVisibility> visibility; List<AssetVisibility> visibility;
@override @override
@ -66,12 +84,13 @@ class TimeBucketAssetResponseDto {
_deepEquality.equals(other.city, city) && _deepEquality.equals(other.city, city) &&
_deepEquality.equals(other.country, country) && _deepEquality.equals(other.country, country) &&
_deepEquality.equals(other.duration, duration) && _deepEquality.equals(other.duration, duration) &&
_deepEquality.equals(other.fileCreatedAt, fileCreatedAt) &&
_deepEquality.equals(other.id, id) && _deepEquality.equals(other.id, id) &&
_deepEquality.equals(other.isFavorite, isFavorite) && _deepEquality.equals(other.isFavorite, isFavorite) &&
_deepEquality.equals(other.isImage, isImage) && _deepEquality.equals(other.isImage, isImage) &&
_deepEquality.equals(other.isTrashed, isTrashed) && _deepEquality.equals(other.isTrashed, isTrashed) &&
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) && _deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
_deepEquality.equals(other.localDateTime, localDateTime) && _deepEquality.equals(other.localOffsetHours, localOffsetHours) &&
_deepEquality.equals(other.ownerId, ownerId) && _deepEquality.equals(other.ownerId, ownerId) &&
_deepEquality.equals(other.projectionType, projectionType) && _deepEquality.equals(other.projectionType, projectionType) &&
_deepEquality.equals(other.ratio, ratio) && _deepEquality.equals(other.ratio, ratio) &&
@ -85,12 +104,13 @@ class TimeBucketAssetResponseDto {
(city.hashCode) + (city.hashCode) +
(country.hashCode) + (country.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(fileCreatedAt.hashCode) +
(id.hashCode) + (id.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(isImage.hashCode) + (isImage.hashCode) +
(isTrashed.hashCode) + (isTrashed.hashCode) +
(livePhotoVideoId.hashCode) + (livePhotoVideoId.hashCode) +
(localDateTime.hashCode) + (localOffsetHours.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(projectionType.hashCode) + (projectionType.hashCode) +
(ratio.hashCode) + (ratio.hashCode) +
@ -99,19 +119,20 @@ class TimeBucketAssetResponseDto {
(visibility.hashCode); (visibility.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'city'] = this.city; json[r'city'] = this.city;
json[r'country'] = this.country; json[r'country'] = this.country;
json[r'duration'] = this.duration; json[r'duration'] = this.duration;
json[r'fileCreatedAt'] = this.fileCreatedAt;
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite; json[r'isFavorite'] = this.isFavorite;
json[r'isImage'] = this.isImage; json[r'isImage'] = this.isImage;
json[r'isTrashed'] = this.isTrashed; json[r'isTrashed'] = this.isTrashed;
json[r'livePhotoVideoId'] = this.livePhotoVideoId; json[r'livePhotoVideoId'] = this.livePhotoVideoId;
json[r'localDateTime'] = this.localDateTime; json[r'localOffsetHours'] = this.localOffsetHours;
json[r'ownerId'] = this.ownerId; json[r'ownerId'] = this.ownerId;
json[r'projectionType'] = this.projectionType; json[r'projectionType'] = this.projectionType;
json[r'ratio'] = this.ratio; json[r'ratio'] = this.ratio;
@ -139,6 +160,9 @@ class TimeBucketAssetResponseDto {
duration: json[r'duration'] is Iterable duration: json[r'duration'] is Iterable
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false) ? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
fileCreatedAt: json[r'fileCreatedAt'] is Iterable
? (json[r'fileCreatedAt'] as Iterable).cast<String>().toList(growable: false)
: const [],
id: json[r'id'] is Iterable id: json[r'id'] is Iterable
? (json[r'id'] as Iterable).cast<String>().toList(growable: false) ? (json[r'id'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
@ -154,8 +178,8 @@ class TimeBucketAssetResponseDto {
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false) ? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
localDateTime: json[r'localDateTime'] is Iterable localOffsetHours: json[r'localOffsetHours'] is Iterable
? (json[r'localDateTime'] as Iterable).cast<String>().toList(growable: false) ? (json[r'localOffsetHours'] as Iterable).cast<num>().toList(growable: false)
: const [], : const [],
ownerId: json[r'ownerId'] is Iterable ownerId: json[r'ownerId'] is Iterable
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false) ? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
@ -225,12 +249,13 @@ class TimeBucketAssetResponseDto {
'city', 'city',
'country', 'country',
'duration', 'duration',
'fileCreatedAt',
'id', 'id',
'isFavorite', 'isFavorite',
'isImage', 'isImage',
'isTrashed', 'isTrashed',
'livePhotoVideoId', 'livePhotoVideoId',
'localDateTime', 'localOffsetHours',
'ownerId', 'ownerId',
'projectionType', 'projectionType',
'ratio', 'ratio',

View File

@ -17,8 +17,10 @@ class TimeBucketsResponseDto {
required this.timeBucket, required this.timeBucket,
}); });
/// Number of assets in this time bucket
int count; int count;
/// Time bucket identifier in YYYY-MM-DD format representing the start of the time period
String timeBucket; String timeBucket;
@override @override

View File

@ -7343,6 +7343,7 @@
"name": "albumId", "name": "albumId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets belonging to a specific album",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7352,6 +7353,7 @@
"name": "isFavorite", "name": "isFavorite",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by favorite status (true for favorites only, false for non-favorites only)",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7360,6 +7362,7 @@
"name": "isTrashed", "name": "isTrashed",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by trash status (true for trashed assets only, false for non-trashed only)",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7376,6 +7379,7 @@
"name": "order", "name": "order",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)",
"schema": { "schema": {
"$ref": "#/components/schemas/AssetOrder" "$ref": "#/components/schemas/AssetOrder"
} }
@ -7384,6 +7388,7 @@
"name": "personId", "name": "personId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets containing a specific person (face recognition)",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7393,6 +7398,7 @@
"name": "tagId", "name": "tagId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets with a specific tag",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7402,7 +7408,9 @@
"name": "timeBucket", "name": "timeBucket",
"required": true, "required": true,
"in": "query", "in": "query",
"description": "Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)",
"schema": { "schema": {
"example": "2024-01-01",
"type": "string" "type": "string"
} }
}, },
@ -7410,6 +7418,7 @@
"name": "userId", "name": "userId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets by specific user ID",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7419,6 +7428,7 @@
"name": "visibility", "name": "visibility",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)",
"schema": { "schema": {
"$ref": "#/components/schemas/AssetVisibility" "$ref": "#/components/schemas/AssetVisibility"
} }
@ -7427,6 +7437,7 @@
"name": "withPartners", "name": "withPartners",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Include assets shared by partners",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7435,6 +7446,7 @@
"name": "withStacked", "name": "withStacked",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7476,6 +7488,7 @@
"name": "albumId", "name": "albumId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets belonging to a specific album",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7485,6 +7498,7 @@
"name": "isFavorite", "name": "isFavorite",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by favorite status (true for favorites only, false for non-favorites only)",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7493,6 +7507,7 @@
"name": "isTrashed", "name": "isTrashed",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by trash status (true for trashed assets only, false for non-trashed only)",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7509,6 +7524,7 @@
"name": "order", "name": "order",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)",
"schema": { "schema": {
"$ref": "#/components/schemas/AssetOrder" "$ref": "#/components/schemas/AssetOrder"
} }
@ -7517,6 +7533,7 @@
"name": "personId", "name": "personId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets containing a specific person (face recognition)",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7526,6 +7543,7 @@
"name": "tagId", "name": "tagId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets with a specific tag",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7535,6 +7553,7 @@
"name": "userId", "name": "userId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter assets by specific user ID",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7544,6 +7563,7 @@
"name": "visibility", "name": "visibility",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)",
"schema": { "schema": {
"$ref": "#/components/schemas/AssetVisibility" "$ref": "#/components/schemas/AssetVisibility"
} }
@ -7552,6 +7572,7 @@
"name": "withPartners", "name": "withPartners",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Include assets shared by partners",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -7560,6 +7581,7 @@
"name": "withStacked", "name": "withStacked",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
@ -9369,10 +9391,14 @@
"$ref": "#/components/schemas/ExifResponseDto" "$ref": "#/components/schemas/ExifResponseDto"
}, },
"fileCreatedAt": { "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", "format": "date-time",
"type": "string" "type": "string"
}, },
"fileModifiedAt": { "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", "format": "date-time",
"type": "string" "type": "string"
}, },
@ -9405,6 +9431,8 @@
"type": "string" "type": "string"
}, },
"localDateTime": { "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", "format": "date-time",
"type": "string" "type": "string"
}, },
@ -9466,6 +9494,8 @@
"type": "array" "type": "array"
}, },
"updatedAt": { "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", "format": "date-time",
"type": "string" "type": "string"
}, },
@ -14424,6 +14454,7 @@
"TimeBucketAssetResponseDto": { "TimeBucketAssetResponseDto": {
"properties": { "properties": {
"city": { "city": {
"description": "Array of city names extracted from EXIF GPS data",
"items": { "items": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
@ -14431,6 +14462,7 @@
"type": "array" "type": "array"
}, },
"country": { "country": {
"description": "Array of country names extracted from EXIF GPS data",
"items": { "items": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
@ -14438,56 +14470,72 @@
"type": "array" "type": "array"
}, },
"duration": { "duration": {
"description": "Array of video durations in HH:MM:SS format (null for images)",
"items": { "items": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"type": "array" "type": "array"
}, },
"fileCreatedAt": {
"description": "Array of file creation timestamps in UTC (ISO 8601 format, without timezone)",
"items": {
"type": "string"
},
"type": "array"
},
"id": { "id": {
"description": "Array of asset IDs in the time bucket",
"items": { "items": {
"type": "string" "type": "string"
}, },
"type": "array" "type": "array"
}, },
"isFavorite": { "isFavorite": {
"description": "Array indicating whether each asset is favorited",
"items": { "items": {
"type": "boolean" "type": "boolean"
}, },
"type": "array" "type": "array"
}, },
"isImage": { "isImage": {
"description": "Array indicating whether each asset is an image (false for videos)",
"items": { "items": {
"type": "boolean" "type": "boolean"
}, },
"type": "array" "type": "array"
}, },
"isTrashed": { "isTrashed": {
"description": "Array indicating whether each asset is in the trash",
"items": { "items": {
"type": "boolean" "type": "boolean"
}, },
"type": "array" "type": "array"
}, },
"livePhotoVideoId": { "livePhotoVideoId": {
"description": "Array of live photo video asset IDs (null for non-live photos)",
"items": { "items": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"type": "array" "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": { "items": {
"type": "string" "type": "number"
}, },
"type": "array" "type": "array"
}, },
"ownerId": { "ownerId": {
"description": "Array of owner IDs for each asset",
"items": { "items": {
"type": "string" "type": "string"
}, },
"type": "array" "type": "array"
}, },
"projectionType": { "projectionType": {
"description": "Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\")",
"items": { "items": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
@ -14495,13 +14543,14 @@
"type": "array" "type": "array"
}, },
"ratio": { "ratio": {
"description": "Array of aspect ratios (width/height) for each asset",
"items": { "items": {
"type": "number" "type": "number"
}, },
"type": "array" "type": "array"
}, },
"stack": { "stack": {
"description": "(stack ID, stack asset count) tuple", "description": "Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)",
"items": { "items": {
"items": { "items": {
"type": "string" "type": "string"
@ -14514,6 +14563,7 @@
"type": "array" "type": "array"
}, },
"thumbhash": { "thumbhash": {
"description": "Array of BlurHash strings for generating asset previews (base64 encoded)",
"items": { "items": {
"nullable": true, "nullable": true,
"type": "string" "type": "string"
@ -14521,6 +14571,7 @@
"type": "array" "type": "array"
}, },
"visibility": { "visibility": {
"description": "Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)",
"items": { "items": {
"$ref": "#/components/schemas/AssetVisibility" "$ref": "#/components/schemas/AssetVisibility"
}, },
@ -14531,12 +14582,13 @@
"city", "city",
"country", "country",
"duration", "duration",
"fileCreatedAt",
"id", "id",
"isFavorite", "isFavorite",
"isImage", "isImage",
"isTrashed", "isTrashed",
"livePhotoVideoId", "livePhotoVideoId",
"localDateTime", "localOffsetHours",
"ownerId", "ownerId",
"projectionType", "projectionType",
"ratio", "ratio",
@ -14548,9 +14600,13 @@
"TimeBucketsResponseDto": { "TimeBucketsResponseDto": {
"properties": { "properties": {
"count": { "count": {
"description": "Number of assets in this time bucket",
"example": 42,
"type": "integer" "type": "integer"
}, },
"timeBucket": { "timeBucket": {
"description": "Time bucket identifier in YYYY-MM-DD format representing the start of the time period",
"example": "2024-01-01",
"type": "string" "type": "string"
} }
}, },

View File

@ -312,7 +312,9 @@ export type AssetResponseDto = {
duplicateId?: string | null; duplicateId?: string | null;
duration: string; duration: string;
exifInfo?: ExifResponseDto; 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; 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; fileModifiedAt: string;
hasMetadata: boolean; hasMetadata: boolean;
id: string; id: string;
@ -323,6 +325,7 @@ export type AssetResponseDto = {
/** This property was deprecated in v1.106.0 */ /** This property was deprecated in v1.106.0 */
libraryId?: string | null; libraryId?: string | null;
livePhotoVideoId?: 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; localDateTime: string;
originalFileName: string; originalFileName: string;
originalMimeType?: string; originalMimeType?: string;
@ -337,6 +340,7 @@ export type AssetResponseDto = {
thumbhash: string | null; thumbhash: string | null;
"type": AssetTypeEnum; "type": AssetTypeEnum;
unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; 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; updatedAt: string;
visibility: AssetVisibility; visibility: AssetVisibility;
}; };
@ -1442,25 +1446,43 @@ export type TagUpdateDto = {
color?: string | null; color?: string | null;
}; };
export type TimeBucketAssetResponseDto = { export type TimeBucketAssetResponseDto = {
/** Array of city names extracted from EXIF GPS data */
city: (string | null)[]; city: (string | null)[];
/** Array of country names extracted from EXIF GPS data */
country: (string | null)[]; country: (string | null)[];
/** Array of video durations in HH:MM:SS format (null for images) */
duration: (string | null)[]; 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[]; id: string[];
/** Array indicating whether each asset is favorited */
isFavorite: boolean[]; isFavorite: boolean[];
/** Array indicating whether each asset is an image (false for videos) */
isImage: boolean[]; isImage: boolean[];
/** Array indicating whether each asset is in the trash */
isTrashed: boolean[]; isTrashed: boolean[];
/** Array of live photo video asset IDs (null for non-live photos) */
livePhotoVideoId: (string | null)[]; 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[]; ownerId: string[];
/** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */
projectionType: (string | null)[]; projectionType: (string | null)[];
/** Array of aspect ratios (width/height) for each asset */
ratio: number[]; ratio: number[];
/** (stack ID, stack asset count) tuple */ /** Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets) */
stack?: (string[] | null)[]; stack?: (string[] | null)[];
/** Array of BlurHash strings for generating asset previews (base64 encoded) */
thumbhash: (string | null)[]; thumbhash: (string | null)[];
/** Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED) */
visibility: AssetVisibility[]; visibility: AssetVisibility[];
}; };
export type TimeBucketsResponseDto = { export type TimeBucketsResponseDto = {
/** Number of assets in this time bucket */
count: number; count: number;
/** Time bucket identifier in YYYY-MM-DD format representing the start of the time period */
timeBucket: string; timeBucket: string;
}; };
export type TrashResponseDto = { export type TrashResponseDto = {

View File

@ -22,6 +22,13 @@ export class SanitizedAssetResponseDto {
type!: AssetType; type!: AssetType;
thumbhash!: string | null; thumbhash!: string | null;
originalMimeType?: string; 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; localDateTime!: Date;
duration!: string; duration!: string;
livePhotoVideoId?: string | null; livePhotoVideoId?: string | null;
@ -37,8 +44,29 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
libraryId?: string | null; libraryId?: string | null;
originalPath!: string; originalPath!: string;
originalFileName!: 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; 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; 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; updatedAt!: Date;
isFavorite!: boolean; isFavorite!: boolean;
isArchived!: boolean; isArchived!: boolean;

View File

@ -5,72 +5,143 @@ import { AssetOrder, AssetVisibility } from 'src/enum';
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
export class TimeBucketDto { export class TimeBucketDto {
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true, description: 'Filter assets by specific user ID' })
userId?: string; userId?: string;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true, description: 'Filter assets belonging to a specific album' })
albumId?: string; albumId?: string;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true, description: 'Filter assets containing a specific person (face recognition)' })
personId?: string; personId?: string;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true, description: 'Filter assets with a specific tag' })
tagId?: string; tagId?: string;
@ValidateBoolean({ optional: true }) @ValidateBoolean({
optional: true,
description: 'Filter by favorite status (true for favorites only, false for non-favorites only)',
})
isFavorite?: boolean; 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; 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; withStacked?: boolean;
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true, description: 'Include assets shared by partners' })
withPartners?: boolean; withPartners?: boolean;
@IsEnum(AssetOrder) @IsEnum(AssetOrder)
@Optional() @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; order?: AssetOrder;
@ValidateAssetVisibility({ optional: true }) @ValidateAssetVisibility({
optional: true,
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
})
visibility?: AssetVisibility; visibility?: AssetVisibility;
} }
export class TimeBucketAssetDto extends TimeBucketDto { 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() @IsString()
timeBucket!: string; timeBucket!: string;
} }
export class TimelineStackResponseDto {
id!: string;
primaryAssetId!: string;
assetCount!: number;
}
export class TimeBucketAssetResponseDto { export class TimeBucketAssetResponseDto {
@ApiProperty({
type: 'array',
items: { type: 'string' },
description: 'Array of asset IDs in the time bucket',
})
id!: string[]; id!: string[];
@ApiProperty({
type: 'array',
items: { type: 'string' },
description: 'Array of owner IDs for each asset',
})
ownerId!: string[]; ownerId!: string[];
@ApiProperty({
type: 'array',
items: { type: 'number' },
description: 'Array of aspect ratios (width/height) for each asset',
})
ratio!: number[]; ratio!: number[];
@ApiProperty({
type: 'array',
items: { type: 'boolean' },
description: 'Array indicating whether each asset is favorited',
})
isFavorite!: boolean[]; 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[]; visibility!: AssetVisibility[];
@ApiProperty({
type: 'array',
items: { type: 'boolean' },
description: 'Array indicating whether each asset is in the trash',
})
isTrashed!: boolean[]; isTrashed!: boolean[];
@ApiProperty({
type: 'array',
items: { type: 'boolean' },
description: 'Array indicating whether each asset is an image (false for videos)',
})
isImage!: boolean[]; 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)[]; 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)[]; duration!: (string | null)[];
@ApiProperty({ @ApiProperty({
@ -82,27 +153,51 @@ export class TimeBucketAssetResponseDto {
maxItems: 2, maxItems: 2,
nullable: true, 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)[]; 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)[]; 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)[]; 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)[]; 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)[]; country!: (string | null)[];
} }
export class TimeBucketsResponseDto { 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; timeBucket!: string;
@ApiProperty({ type: 'integer' }) @ApiProperty({
type: 'integer',
description: 'Number of assets in this time bucket',
example: 42,
})
count!: number; count!: number;
} }

View File

@ -242,7 +242,7 @@ with
and "assets"."visibility" in ('archive', 'timeline') and "assets"."visibility" in ('archive', 'timeline')
) )
select select
"timeBucket", "timeBucket"::date::text as "timeBucket",
count(*) as "count" count(*) as "count"
from from
"assets" "assets"
@ -262,9 +262,16 @@ with
assets.type = 'IMAGE' as "isImage", assets.type = 'IMAGE' as "isImage",
assets."deletedAt" is not null as "isTrashed", assets."deletedAt" is not null as "isTrashed",
"assets"."livePhotoVideoId", "assets"."livePhotoVideoId",
"assets"."localDateTime", extract(
epoch
from
(
assets."localDateTime" - assets."fileCreatedAt" at time zone 'UTC'
)
)::real / 3600 as "localOffsetHours",
"assets"."ownerId", "assets"."ownerId",
"assets"."status", "assets"."status",
assets."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
encode("assets"."thumbhash", 'base64') as "thumbhash", encode("assets"."thumbhash", 'base64') as "thumbhash",
"exif"."city", "exif"."city",
"exif"."country", "exif"."country",
@ -313,7 +320,7 @@ with
and "asset_stack"."primaryAssetId" != "assets"."id" and "asset_stack"."primaryAssetId" != "assets"."id"
) )
order by order by
"assets"."localDateTime" desc "assets"."fileCreatedAt" desc
), ),
"agg" as ( "agg" as (
select select
@ -326,7 +333,8 @@ with
coalesce(array_agg("isImage"), '{}') as "isImage", coalesce(array_agg("isImage"), '{}') as "isImage",
coalesce(array_agg("isTrashed"), '{}') as "isTrashed", coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId", 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("ownerId"), '{}') as "ownerId",
coalesce(array_agg("projectionType"), '{}') as "projectionType", coalesce(array_agg("projectionType"), '{}') as "projectionType",
coalesce(array_agg("ratio"), '{}') as "ratio", coalesce(array_agg("ratio"), '{}') as "ratio",

View File

@ -532,51 +532,44 @@ export class AssetRepository {
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> { async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
return ( return this.db
this.db .with('assets', (qb) =>
.with('assets', (qb) => qb
qb .selectFrom('assets')
.selectFrom('assets') .select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
.select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility)
.$if(options.visibility === undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) .$if(!!options.albumId, (qb) =>
.$if(!!options.albumId, (qb) => qb
qb .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId')
.innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
.where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), )
) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.withStacked, (qb) =>
.$if(!!options.withStacked, (qb) => qb
qb .leftJoin('asset_stack', (join) =>
.leftJoin('asset_stack', (join) => join
join .onRef('asset_stack.id', '=', 'assets.stackId')
.onRef('asset_stack.id', '=', 'assets.stackId') .onRef('asset_stack.primaryAssetId', '=', 'assets.id'),
.onRef('asset_stack.primaryAssetId', '=', 'assets.id'), )
) .where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])),
.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.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) =>
.$if(options.isDuplicate !== undefined, (qb) => qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), )
) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), )
) .selectFrom('assets')
.selectFrom('assets') .select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
.select('timeBucket') .select((eb) => eb.fn.countAll<number>().as('count'))
/* .groupBy('timeBucket')
TODO: the above line outputs in ISO format, which bloats the response. .orderBy('timeBucket', options.order ?? 'desc')
The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work. .execute() as any as Promise<TimeBucketItem[]>;
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
*/
.select((eb) => eb.fn.countAll<number>().as('count'))
.groupBy('timeBucket')
.orderBy('timeBucket', options.order ?? 'desc')
.execute() as any as Promise<TimeBucketItem[]>
);
} }
@GenerateSql({ @GenerateSql({
@ -596,9 +589,12 @@ export class AssetRepository {
sql`assets.type = 'IMAGE'`.as('isImage'), sql`assets.type = 'IMAGE'`.as('isImage'),
sql`assets."deletedAt" is not null`.as('isTrashed'), sql`assets."deletedAt" is not null`.as('isTrashed'),
'assets.livePhotoVideoId', 'assets.livePhotoVideoId',
'assets.localDateTime', sql`extract(epoch from (assets."localDateTime" - assets."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as(
'localOffsetHours',
),
'assets.ownerId', 'assets.ownerId',
'assets.status', 'assets.status',
sql`assets."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'), eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
'exif.city', 'exif.city',
'exif.country', 'exif.country',
@ -666,7 +662,7 @@ export class AssetRepository {
) )
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy('assets.localDateTime', options.order ?? 'desc'), .orderBy('assets.fileCreatedAt', options.order ?? 'desc'),
) )
.with('agg', (qb) => .with('agg', (qb) =>
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 // 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', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'), 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', ['ownerId']), sql.lit('{}')).as('ownerId'),
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'), eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'), eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),

View File

@ -6,7 +6,7 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
applyDecorators, applyDecorators,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsArray, IsArray,
@ -72,22 +72,28 @@ export class UUIDParamDto {
} }
type PinCodeOptions = { optional?: boolean } & OptionalOptions; 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 = [ const decorators = [
IsString(), IsString(),
IsNotEmpty(), IsNotEmpty(),
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }), Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
ApiProperty({ example: '123456' }), ApiProperty({ example: '123456', ...apiPropertyOptions }),
]; ];
if (optional) { if (optional) {
decorators.push(Optional(options)); decorators.push(Optional({ nullable, emptyToNull }));
} }
return applyDecorators(...decorators); return applyDecorators(...decorators);
}; };
export interface OptionalOptions extends ValidationOptions { export interface OptionalOptions {
nullable?: boolean; nullable?: boolean;
/** convert empty strings to null */ /** convert empty strings to null */
emptyToNull?: boolean; emptyToNull?: boolean;
@ -127,22 +133,32 @@ export const ValidateHexColor = () => {
}; };
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
export const ValidateUUID = (options?: UUIDOptions) => { export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => {
const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options }; const { optional, each, nullable, ...apiPropertyOptions } = {
optional: false,
each: false,
nullable: false,
...options,
};
return applyDecorators( return applyDecorators(
IsUUID('4', { each }), IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }), ApiProperty({ format: 'uuid', ...apiPropertyOptions }),
optional ? Optional({ nullable }) : IsNotEmpty(), optional ? Optional({ nullable }) : IsNotEmpty(),
each ? IsArray() : IsString(), each ? IsArray() : IsString(),
); );
}; };
type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' };
export const ValidateDate = (options?: DateOptions) => { export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; const { optional, nullable, format, ...apiPropertyOptions } = {
optional: false,
nullable: false,
format: 'date-time',
...options,
};
const decorators = [ const decorators = [
ApiProperty({ format }), ApiProperty({ format, ...apiPropertyOptions }),
IsDate(), IsDate(),
optional ? Optional({ nullable: true }) : IsNotEmpty(), optional ? Optional({ nullable: true }) : IsNotEmpty(),
Transform(({ key, value }) => { Transform(({ key, value }) => {
@ -166,9 +182,12 @@ export const ValidateDate = (options?: DateOptions) => {
}; };
type AssetVisibilityOptions = { optional?: boolean }; type AssetVisibilityOptions = { optional?: boolean };
export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { export const ValidateAssetVisibility = (options?: AssetVisibilityOptions & ApiPropertyOptions) => {
const { optional } = { optional: false, ...options }; const { optional, ...apiPropertyOptions } = { optional: false, ...options };
const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })]; const decorators = [
IsEnum(AssetVisibility),
ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility, ...apiPropertyOptions }),
];
if (optional) { if (optional) {
decorators.push(Optional()); decorators.push(Optional());
@ -177,10 +196,10 @@ export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => {
}; };
type BooleanOptions = { optional?: boolean }; type BooleanOptions = { optional?: boolean };
export const ValidateBoolean = (options?: BooleanOptions) => { export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => {
const { optional } = { optional: false, ...options }; const { optional, ...apiPropertyOptions } = { optional: false, ...options };
const decorators = [ const decorators = [
// ApiProperty(), ApiProperty(apiPropertyOptions),
IsBoolean(), IsBoolean(),
Transform(({ value }) => { Transform(({ value }) => {
if (value == 'true') { if (value == 'true') {

View File

@ -18,7 +18,7 @@
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util'; import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
import { import {
AssetMediaSize, AssetMediaSize,
getAssetInfo, getAssetInfo,
@ -112,8 +112,8 @@
let timeZone = $derived(asset.exifInfo?.timeZone); let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived( let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal timeZone && asset.exifInfo?.dateTimeOriginal
? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone) ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromLocalDateTime(asset.localDateTime), : fromISODateTimeUTC(asset.localDateTime),
); );
const getMegapixel = (width: number, height: number): number | undefined => { const getMegapixel = (width: number, height: number): number | undefined => {

View File

@ -35,7 +35,7 @@
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util'; 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 { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { import {
@ -576,7 +576,7 @@
<div class="absolute start-8 top-4 text-sm font-medium text-white"> <div class="absolute start-8 top-4 text-sm font-medium text-white">
<p> <p>
{fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
locale: $locale, locale: $locale,
})} })}
</p> </p>

View File

@ -3,10 +3,10 @@ import { handleError } from '$lib/utils/handle-error';
import { import {
formatBucketTitle, formatBucketTitle,
formatGroupTitle, formatGroupTitle,
fromLocalDateTimeToObject,
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
getTimes,
type TimelinePlainDateTime, type TimelinePlainDateTime,
type TimelinePlainYearMonth, type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
@ -153,8 +153,12 @@ export class AssetBucket {
addAssets(bucketAssets: TimeBucketAssetResponseDto) { addAssets(bucketAssets: TimeBucketAssetResponseDto) {
const addContext = new AddContext(); const addContext = new AddContext();
const people: string[] = [];
for (let i = 0; i < bucketAssets.id.length; i++) { for (let i = 0; i < bucketAssets.id.length; i++) {
const { localDateTime, fileCreatedAt } = getTimes(
bucketAssets.fileCreatedAt[i],
bucketAssets.localOffsetHours[i],
);
const timelineAsset: TimelineAsset = { const timelineAsset: TimelineAsset = {
city: bucketAssets.city[i], city: bucketAssets.city[i],
country: bucketAssets.country[i], country: bucketAssets.country[i],
@ -166,9 +170,9 @@ export class AssetBucket {
isTrashed: bucketAssets.isTrashed[i], isTrashed: bucketAssets.isTrashed[i],
isVideo: !bucketAssets.isImage[i], isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime: fromLocalDateTimeToObject(bucketAssets.localDateTime[i]), localDateTime,
fileCreatedAt,
ownerId: bucketAssets.ownerId[i], ownerId: bucketAssets.ownerId[i],
people,
projectionType: bucketAssets.projectionType[i], projectionType: bucketAssets.projectionType[i],
ratio: bucketAssets.ratio[i], ratio: bucketAssets.ratio[i],
stack: bucketAssets.stack?.[i] stack: bucketAssets.stack?.[i]
@ -179,6 +183,7 @@ export class AssetBucket {
} }
: null, : null,
thumbhash: bucketAssets.thumbhash[i], thumbhash: bucketAssets.thumbhash[i],
people: null, // People are not included in the bucket assets
}; };
this.addTimelineAsset(timelineAsset, addContext); this.addTimelineAsset(timelineAsset, addContext);
} }

View File

@ -72,7 +72,7 @@ export class AssetDateGroup {
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc); 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() { getFirstAsset() {

View File

@ -3,7 +3,7 @@ import { websocketEvents } from '$lib/stores/websocket';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { import {
plainDateTimeCompare, plainDateTimeCompare,
toISOLocalDateTime, toISOYearMonthUTC,
toTimelineAsset, toTimelineAsset,
type TimelinePlainDate, type TimelinePlainDate,
type TimelinePlainDateTime, type TimelinePlainDateTime,
@ -573,7 +573,7 @@ export class AssetStore {
if (bucket.getFirstAsset()) { if (bucket.getFirstAsset()) {
return; return;
} }
const timeBucket = toISOLocalDateTime(bucket.yearMonth); const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
const key = authManager.key; const key = authManager.key;
const bucketResponse = await getTimeBucket( const bucketResponse = await getTimeBucket(
{ {

View File

@ -18,6 +18,7 @@ export type TimelineAsset = {
ratio: number; ratio: number;
thumbhash: string | null; thumbhash: string | null;
localDateTime: TimelinePlainDateTime; localDateTime: TimelinePlainDateTime;
fileCreatedAt: TimelinePlainDateTime;
visibility: AssetVisibility; visibility: AssetVisibility;
isFavorite: boolean; isFavorite: boolean;
isTrashed: boolean; isTrashed: boolean;
@ -29,7 +30,7 @@ export type TimelineAsset = {
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
city: string | null; city: string | null;
country: string | null; country: string | null;
people: string[]; people: string[] | null;
}; };
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };

View File

@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte'; import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AbortError } from '$lib/utils'; 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 { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
@ -14,6 +14,13 @@ async function getAssets(store: AssetStore) {
return assets; return assets;
} }
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
return {
...arg,
localDateTime: arg.fileCreatedAt,
};
}
describe('AssetStore', () => { describe('AssetStore', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@ -22,15 +29,24 @@ describe('AssetStore', () => {
describe('init', () => { describe('init', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, TimelineAsset[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory '2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
.buildList(1) deriveLocalDateTimeFromFileCreatedAt({
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })), ...asset,
'2024-02-01T00:00:00.000Z': timelineAssetFactory fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
.buildList(100) }),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })), ),
'2024-01-01T00:00:00.000Z': timelineAssetFactory '2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
.buildList(3) deriveLocalDateTimeFromFileCreatedAt({
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })), ...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<string, TimeBucketAssetResponseDto> = Object.fromEntries( const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
@ -40,9 +56,9 @@ describe('AssetStore', () => {
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore(); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, { count: 1, timeBucket: '2024-03-01' },
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 100, timeBucket: '2024-02-01' },
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01' },
]); ]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
@ -78,12 +94,18 @@ describe('AssetStore', () => {
describe('loadBucket', () => { describe('loadBucket', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, TimelineAsset[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory '2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
.buildList(1) deriveLocalDateTimeFromFileCreatedAt({
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })), ...asset,
'2024-01-01T00:00:00.000Z': timelineAssetFactory fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
.buildList(3) }),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-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<string, TimeBucketAssetResponseDto> = Object.fromEntries( const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
@ -166,9 +188,11 @@ describe('AssetStore', () => {
}); });
it('adds assets to new bucket', () => { it('adds assets to new bucket', () => {
const asset = timelineAssetFactory.build({ const asset = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), timelineAssetFactory.build({
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
@ -180,9 +204,11 @@ describe('AssetStore', () => {
}); });
it('adds assets to existing bucket', () => { it('adds assets to existing bucket', () => {
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { const [assetOne, assetTwo] = timelineAssetFactory
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), .buildList(2, {
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
assetStore.addAssets([assetOne]); assetStore.addAssets([assetOne]);
assetStore.addAssets([assetTwo]); assetStore.addAssets([assetTwo]);
@ -194,15 +220,21 @@ describe('AssetStore', () => {
}); });
it('orders assets in buckets by descending date', () => { it('orders assets in buckets by descending date', () => {
const assetOne = timelineAssetFactory.build({ const assetOne = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), timelineAssetFactory.build({
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
const assetTwo = timelineAssetFactory.build({ }),
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'), );
}); const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
const assetThree = timelineAssetFactory.build({ timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2024-01-16T12:00:00.000Z'), 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]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 }); const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
@ -214,15 +246,21 @@ describe('AssetStore', () => {
}); });
it('orders buckets by descending date', () => { it('orders buckets by descending date', () => {
const assetOne = timelineAssetFactory.build({ const assetOne = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), timelineAssetFactory.build({
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
const assetTwo = timelineAssetFactory.build({ }),
localDateTime: fromLocalDateTimeToObject('2024-04-20T12:00:00.000Z'), );
}); const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
const assetThree = timelineAssetFactory.build({ timelineAssetFactory.build({
localDateTime: fromLocalDateTimeToObject('2023-01-20T12:00:00.000Z'), 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]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
expect(assetStore.buckets.length).toEqual(3); expect(assetStore.buckets.length).toEqual(3);
@ -238,7 +276,7 @@ describe('AssetStore', () => {
it('updates existing asset', () => { it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets'); const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
const asset = timelineAssetFactory.build(); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@ -248,8 +286,8 @@ describe('AssetStore', () => {
// disabled due to the wasm Justified Layout import // disabled due to the wasm Justified Layout import
it('ignores trashed assets when isTrashed is true', async () => { it('ignores trashed assets when isTrashed is true', async () => {
const asset = timelineAssetFactory.build({ isTrashed: false }); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
const trashedAsset = timelineAssetFactory.build({ isTrashed: true }); const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
const assetStore = new AssetStore(); const assetStore = new AssetStore();
await assetStore.updateOptions({ isTrashed: true }); await assetStore.updateOptions({ isTrashed: true });
@ -269,14 +307,14 @@ describe('AssetStore', () => {
}); });
it('ignores non-existing assets', () => { it('ignores non-existing assets', () => {
assetStore.updateAssets([timelineAssetFactory.build()]); assetStore.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
expect(assetStore.buckets.length).toEqual(0); expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.count).toEqual(0); expect(assetStore.count).toEqual(0);
}); });
it('updates an asset', () => { it('updates an asset', () => {
const asset = timelineAssetFactory.build({ isFavorite: false }); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
const updatedAsset = { ...asset, isFavorite: true }; const updatedAsset = { ...asset, isFavorite: true };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@ -289,10 +327,15 @@ describe('AssetStore', () => {
}); });
it('asset moves buckets when asset date changes', () => { it('asset moves buckets when asset date changes', () => {
const asset = timelineAssetFactory.build({ const asset = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), 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]); assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1); expect(assetStore.buckets.length).toEqual(1);
@ -320,7 +363,11 @@ describe('AssetStore', () => {
it('ignores invalid IDs', () => { it('ignores invalid IDs', () => {
assetStore.addAssets( 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']); assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
@ -330,9 +377,11 @@ describe('AssetStore', () => {
}); });
it('removes asset from bucket', () => { it('removes asset from bucket', () => {
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { const [assetOne, assetTwo] = timelineAssetFactory
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), .buildList(2, {
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]); assetStore.removeAssets([assetOne.id]);
@ -342,9 +391,11 @@ describe('AssetStore', () => {
}); });
it('does not remove bucket when empty', () => { it('does not remove bucket when empty', () => {
const assets = timelineAssetFactory.buildList(2, { const assets = timelineAssetFactory
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), .buildList(2, {
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
})
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
assetStore.addAssets(assets); assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id)); assetStore.removeAssets(assets.map((asset) => asset.id));
@ -367,12 +418,16 @@ describe('AssetStore', () => {
}); });
it('populated store returns first asset', () => { it('populated store returns first asset', () => {
const assetOne = timelineAssetFactory.build({ const assetOne = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), timelineAssetFactory.build({
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
const assetTwo = timelineAssetFactory.build({ }),
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'), );
}); const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
}),
);
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getFirstAsset()).toEqual(assetOne); expect(assetStore.getFirstAsset()).toEqual(assetOne);
}); });
@ -381,15 +436,24 @@ describe('AssetStore', () => {
describe('getLaterAsset', () => { describe('getLaterAsset', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, TimelineAsset[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory '2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
.buildList(1) deriveLocalDateTimeFromFileCreatedAt({
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })), ...asset,
'2024-02-01T00:00:00.000Z': timelineAssetFactory fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
.buildList(6) }),
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })), ),
'2024-01-01T00:00:00.000Z': timelineAssetFactory '2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
.buildList(3) deriveLocalDateTimeFromFileCreatedAt({
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })), ...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<string, TimeBucketAssetResponseDto> = Object.fromEntries( const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
@ -479,12 +543,16 @@ describe('AssetStore', () => {
}); });
it('returns the bucket index', () => { it('returns the bucket index', () => {
const assetOne = timelineAssetFactory.build({ const assetOne = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), timelineAssetFactory.build({
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
const assetTwo = timelineAssetFactory.build({ }),
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'), );
}); const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024); expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
@ -494,12 +562,16 @@ describe('AssetStore', () => {
}); });
it('ignores removed buckets', () => { it('ignores removed buckets', () => {
const assetOne = timelineAssetFactory.build({ const assetOne = deriveLocalDateTimeFromFileCreatedAt(
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'), timelineAssetFactory.build({
}); fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
const assetTwo = timelineAssetFactory.build({ }),
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'), );
}); const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
}),
);
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetTwo.id]); assetStore.removeAssets([assetTwo.id]);

View File

@ -62,6 +62,15 @@ describe('getAltText', () => {
ownerId: 'test-owner', ownerId: 'test-owner',
ratio: 1, ratio: 1,
thumbhash: null, 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: { localDateTime: {
year: testDate.getUTCFullYear(), year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
@ -71,6 +80,7 @@ describe('getAltText', () => {
second: testDate.getUTCSeconds(), second: testDate.getUTCSeconds(),
millisecond: testDate.getUTCMilliseconds(), millisecond: testDate.getUTCMilliseconds(),
}, },
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
isFavorite: false, isFavorite: false,
isTrashed: false, isTrashed: false,

View File

@ -46,16 +46,16 @@ export const getAltText = derived(t, ($t) => {
}); });
const hasPlace = asset.city && asset.country; const hasPlace = asset.city && asset.country;
const peopleCount = asset.people.length; const peopleCount = asset.people?.length ?? 0;
const isVideo = asset.isVideo; const isVideo = asset.isVideo;
const values = { const values = {
date, date,
city: asset.city, city: asset.city,
country: asset.country, country: asset.country,
person1: asset.people[0], person1: asset.people?.[0],
person2: asset.people[1], person2: asset.people?.[1],
person3: asset.people[2], person3: asset.people?.[2],
isVideo, isVideo,
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0, additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
}; };

View File

@ -5,17 +5,81 @@ import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { DateTime, type LocaleOptions } from 'luxon'; import { DateTime, type LocaleOptions } from 'luxon';
import { get } from 'svelte/store'; 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 = ( export type ScrubberListener = (
bucketDate: { year: number; month: number }, bucketDate: { year: number; month: number },
overallScrollPercent: number, overallScrollPercent: number,
bucketScrollPercent: number, bucketScrollPercent: number,
) => void | Promise<void>; ) => void | Promise<void>;
export const fromLocalDateTime = (localDateTime: string) => // used for AssetResponseDto.dateTimeOriginal, amongst others
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime<true>;
export const fromLocalDateTimeToObject = (localDateTime: string): TimelinePlainDateTime => export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelinePlainDateTime =>
(fromLocalDateTime(localDateTime) as DateTime<true>).toObject(); (fromISODateTime(isoDateTime, timeZone) as DateTime<true>).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<true>).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<true>
).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<true>).toObject();
};
export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => {
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
const fileCreatedAt = (utcDateTime as DateTime<true>).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<true>).toObject();
return {
fileCreatedAt,
localDateTime,
};
};
export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> => export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> =>
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>; DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
@ -32,10 +96,7 @@ export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearM
{ zone: 'local', locale: get(locale) }, { zone: 'local', locale: get(locale) },
) as DateTime<true>; ) as DateTime<true>;
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string =>
DateTime.fromISO(dateTimeOriginal, { zone: timeZone, locale: get(locale) });
export const toISOLocalDateTime = (timelineYearMonth: TimelinePlainYearMonth): string =>
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO(); (fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
export function formatBucketTitle(_date: DateTime): string { export function formatBucketTitle(_date: DateTime): string {
@ -104,12 +165,16 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
const country = assetResponse.exifInfo?.country; const country = assetResponse.exifInfo?.country;
const people = assetResponse.people?.map((person) => person.name) || []; const people = assetResponse.people?.map((person) => person.name) || [];
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
return { return {
id: assetResponse.id, id: assetResponse.id,
ownerId: assetResponse.ownerId, ownerId: assetResponse.ownerId,
ratio, ratio,
thumbhash: assetResponse.thumbhash, thumbhash: assetResponse.thumbhash,
localDateTime: fromLocalDateTimeToObject(assetResponse.localDateTime), localDateTime,
fileCreatedAt,
isFavorite: assetResponse.isFavorite, isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility, visibility: assetResponse.visibility,
isTrashed: assetResponse.isTrashed, isTrashed: assetResponse.isTrashed,
@ -151,19 +216,3 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTim
} }
return aDateTime.millisecond - bDateTime.millisecond; 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;
};

View File

@ -1,5 +1,5 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; 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 { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts'; import { Sync } from 'factory.ts';
@ -34,7 +34,8 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
ratio: Sync.each(() => faker.number.int()), ratio: Sync.each(() => faker.number.int()),
ownerId: Sync.each(() => faker.string.uuid()), ownerId: Sync.each(() => faker.string.uuid()),
thumbhash: Sync.each(() => faker.string.alphanumeric(28)), 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()), isFavorite: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
isTrashed: false, isTrashed: false,
@ -60,7 +61,8 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
isImage: [], isImage: [],
isTrashed: [], isTrashed: [],
livePhotoVideoId: [], livePhotoVideoId: [],
localDateTime: [], fileCreatedAt: [],
localOffsetHours: [],
ownerId: [], ownerId: [],
projectionType: [], projectionType: [],
ratio: [], ratio: [],
@ -68,6 +70,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
thumbhash: [], thumbhash: [],
}; };
for (const asset of timelineAsset) { for (const asset of timelineAsset) {
const fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt).toISO();
bucketAssets.city.push(asset.city); bucketAssets.city.push(asset.city);
bucketAssets.country.push(asset.country); bucketAssets.country.push(asset.country);
bucketAssets.duration.push(asset.duration!); bucketAssets.duration.push(asset.duration!);
@ -77,7 +80,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
bucketAssets.isImage.push(asset.isImage); bucketAssets.isImage.push(asset.isImage);
bucketAssets.isTrashed.push(asset.isTrashed); bucketAssets.isTrashed.push(asset.isTrashed);
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!); bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
bucketAssets.localDateTime.push(fromTimelinePlainDateTime(asset.localDateTime).toISO()); bucketAssets.fileCreatedAt.push(fileCreatedAt);
bucketAssets.ownerId.push(asset.ownerId); bucketAssets.ownerId.push(asset.ownerId);
bucketAssets.projectionType.push(asset.projectionType!); bucketAssets.projectionType.push(asset.projectionType!);
bucketAssets.ratio.push(asset.ratio); bucketAssets.ratio.push(asset.ratio);