diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index 93ba8b6527..79bf748e9a 100644 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { DateTime } from 'luxon'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -52,7 +52,7 @@ describe('/timeline', () => { describe('GET /timeline/buckets', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month }); + const { status, body } = await request(app).get('/timeline/buckets'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -60,8 +60,7 @@ describe('/timeline', () => { it('should get time buckets by month', async () => { const { status, body } = await request(app) .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month }); + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); expect(status).toBe(200); expect(body).toEqual( @@ -78,33 +77,17 @@ describe('/timeline', () => { assetIds: userAssets.map(({ id }) => id), }); - const { status, body } = await request(app) - .get('/timeline/buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key }); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); }); - it('should get time buckets by day', async () => { - const { status, body } = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Day }); - - expect(status).toBe(200); - expect(body).toEqual([ - { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]); - }); - it('should return error if time bucket is requested with partners asset and archived', async () => { const req1 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive }); + .query({ withPartners: true, visibility: AssetVisibility.Archive }); expect(req1.status).toBe(400); expect(req1.body).toEqual(errorDto.badRequest()); @@ -112,7 +95,7 @@ describe('/timeline', () => { const req2 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${user.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined }); + .query({ withPartners: true, visibility: undefined }); expect(req2.status).toBe(400); expect(req2.body).toEqual(errorDto.badRequest()); @@ -122,7 +105,7 @@ describe('/timeline', () => { const req1 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + .query({ withPartners: true, isFavorite: true }); expect(req1.status).toBe(400); expect(req1.body).toEqual(errorDto.badRequest()); @@ -130,7 +113,7 @@ describe('/timeline', () => { const req2 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + .query({ withPartners: true, isFavorite: false }); expect(req2.status).toBe(400); expect(req2.body).toEqual(errorDto.badRequest()); @@ -140,7 +123,7 @@ describe('/timeline', () => { const req = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${user.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + .query({ withPartners: true, isTrashed: true }); expect(req.status).toBe(400); expect(req.body).toEqual(errorDto.badRequest()); @@ -150,7 +133,6 @@ describe('/timeline', () => { describe('GET /timeline/bucket', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/timeline/bucket').query({ - size: TimeBucketSize.Month, timeBucket: '1900-01-01', }); @@ -161,11 +143,27 @@ describe('/timeline', () => { it('should handle 5 digit years', async () => { const { status, body } = await request(app) .get('/timeline/bucket') - .query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' }) + .query({ timeBucket: '012345-01-01' }) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([]); + expect(body).toEqual({ + city: [], + country: [], + duration: [], + id: [], + visibility: [], + isFavorite: [], + isImage: [], + isTrashed: [], + livePhotoVideoId: [], + localDateTime: [], + ownerId: [], + projectionType: [], + ratio: [], + status: [], + thumbhash: [], + }); }); // TODO enable date string validation while still accepting 5 digit years @@ -173,7 +171,7 @@ describe('/timeline', () => { // const { status, body } = await request(app) // .get('/timeline/bucket') // .set('Authorization', `Bearer ${user.accessToken}`) - // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + // .query({ timeBucket: 'foo' }); // expect(status).toBe(400); // expect(body).toEqual(errorDto.badRequest); @@ -183,10 +181,26 @@ describe('/timeline', () => { const { status, body } = await request(app) .get('/timeline/bucket') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' }); + .query({ timeBucket: '1970-02-10' }); expect(status).toBe(200); - expect(body).toEqual([]); + expect(body).toEqual({ + city: [], + country: [], + duration: [], + id: [], + visibility: [], + isFavorite: [], + isImage: [], + isTrashed: [], + livePhotoVideoId: [], + localDateTime: [], + ownerId: [], + projectionType: [], + ratio: [], + status: [], + thumbhash: [], + }); }); }); }); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 620fc97664..2c5dea7f19 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -494,8 +494,8 @@ Class | Method | HTTP request | Description - [TemplateDto](doc//TemplateDto.md) - [TemplateResponseDto](doc//TemplateResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - - [TimeBucketSize](doc//TimeBucketSize.md) + - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md) + - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md) - [ToneMapping](doc//ToneMapping.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodePolicy](doc//TranscodePolicy.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8710298d7d..541614ca55 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -289,8 +289,8 @@ part 'model/tags_update.dart'; part 'model/template_dto.dart'; part 'model/template_response_dto.dart'; part 'model/test_email_response_dto.dart'; -part 'model/time_bucket_response_dto.dart'; -part 'model/time_bucket_size.dart'; +part 'model/time_bucket_asset_response_dto.dart'; +part 'model/time_buckets_response_dto.dart'; part 'model/tone_mapping.dart'; part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 1d25a379e8..399e7bde86 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -19,8 +19,6 @@ class TimelineApi { /// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response]. /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] timeBucket (required): /// /// * [String] albumId: @@ -33,6 +31,10 @@ class TimelineApi { /// /// * [AssetOrder] order: /// + /// * [num] page: + /// + /// * [num] pageSize: + /// /// * [String] personId: /// /// * [String] tagId: @@ -44,7 +46,7 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, 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, num? page, num? pageSize, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -70,10 +72,15 @@ class TimelineApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } + if (pageSize != null) { + queryParams.addAll(_queryParams('', 'pageSize', pageSize)); + } if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } - queryParams.addAll(_queryParams('', 'size', size)); if (tagId != null) { queryParams.addAll(_queryParams('', 'tagId', tagId)); } @@ -107,8 +114,6 @@ class TimelineApi { /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] timeBucket (required): /// /// * [String] albumId: @@ -121,6 +126,10 @@ class TimelineApi { /// /// * [AssetOrder] order: /// + /// * [num] page: + /// + /// * [num] pageSize: + /// /// * [String] personId: /// /// * [String] tagId: @@ -132,8 +141,8 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, 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(size, timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); + Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, 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, page: page, pageSize: pageSize, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -141,11 +150,8 @@ class TimelineApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List) - .cast<AssetResponseDto>() - .toList(growable: false); - + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TimeBucketAssetResponseDto',) as TimeBucketAssetResponseDto; + } return null; } @@ -153,8 +159,6 @@ class TimelineApi { /// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response]. /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] albumId: /// /// * [bool] isFavorite: @@ -176,7 +180,7 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { 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 final apiPath = r'/timeline/buckets'; @@ -205,7 +209,6 @@ class TimelineApi { if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } - queryParams.addAll(_queryParams('', 'size', size)); if (tagId != null) { queryParams.addAll(_queryParams('', 'tagId', tagId)); } @@ -238,8 +241,6 @@ class TimelineApi { /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] albumId: /// /// * [bool] isFavorite: @@ -261,8 +262,8 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { 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(size, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); + 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, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -271,8 +272,8 @@ class TimelineApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketResponseDto>') as List) - .cast<TimeBucketResponseDto>() + return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketsResponseDto>') as List) + .cast<TimeBucketsResponseDto>() .toList(growable: false); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a3b1c41ca6..540dc11300 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -634,10 +634,10 @@ class ApiClient { return TemplateResponseDto.fromJson(value); case 'TestEmailResponseDto': return TestEmailResponseDto.fromJson(value); - case 'TimeBucketResponseDto': - return TimeBucketResponseDto.fromJson(value); - case 'TimeBucketSize': - return TimeBucketSizeTypeTransformer().decode(value); + case 'TimeBucketAssetResponseDto': + return TimeBucketAssetResponseDto.fromJson(value); + case 'TimeBucketsResponseDto': + return TimeBucketsResponseDto.fromJson(value); case 'ToneMapping': return ToneMappingTypeTransformer().decode(value); case 'TranscodeHWAccel': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4928adf767..1618f4a670 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -139,9 +139,6 @@ String parameterToString(dynamic value) { if (value is SyncRequestType) { return SyncRequestTypeTypeTransformer().encode(value).toString(); } - if (value is TimeBucketSize) { - return TimeBucketSizeTypeTransformer().encode(value).toString(); - } if (value is ToneMapping) { return ToneMappingTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart new file mode 100644 index 0000000000..3f1406c019 --- /dev/null +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -0,0 +1,241 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TimeBucketAssetResponseDto { + /// Returns a new [TimeBucketAssetResponseDto] instance. + TimeBucketAssetResponseDto({ + this.city = const [], + this.country = const [], + this.duration = const [], + this.id = const [], + this.isFavorite = const [], + this.isImage = const [], + this.isTrashed = const [], + this.livePhotoVideoId = const [], + this.localDateTime = const [], + this.ownerId = const [], + this.projectionType = const [], + this.ratio = const [], + this.stack = const [], + this.thumbhash = const [], + this.visibility = const [], + }); + + List<String?> city; + + List<String?> country; + + List<String?> duration; + + List<String> id; + + List<bool> isFavorite; + + List<bool> isImage; + + List<bool> isTrashed; + + List<String?> livePhotoVideoId; + + List<String> localDateTime; + + List<String> ownerId; + + List<String?> projectionType; + + List<num> ratio; + + /// (stack ID, stack asset count) tuple + List<List<String>?> stack; + + List<String?> thumbhash; + + List<AssetVisibility> visibility; + + @override + bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto && + _deepEquality.equals(other.city, city) && + _deepEquality.equals(other.country, country) && + _deepEquality.equals(other.duration, duration) && + _deepEquality.equals(other.id, id) && + _deepEquality.equals(other.isFavorite, isFavorite) && + _deepEquality.equals(other.isImage, isImage) && + _deepEquality.equals(other.isTrashed, isTrashed) && + _deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) && + _deepEquality.equals(other.localDateTime, localDateTime) && + _deepEquality.equals(other.ownerId, ownerId) && + _deepEquality.equals(other.projectionType, projectionType) && + _deepEquality.equals(other.ratio, ratio) && + _deepEquality.equals(other.stack, stack) && + _deepEquality.equals(other.thumbhash, thumbhash) && + _deepEquality.equals(other.visibility, visibility); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city.hashCode) + + (country.hashCode) + + (duration.hashCode) + + (id.hashCode) + + (isFavorite.hashCode) + + (isImage.hashCode) + + (isTrashed.hashCode) + + (livePhotoVideoId.hashCode) + + (localDateTime.hashCode) + + (ownerId.hashCode) + + (projectionType.hashCode) + + (ratio.hashCode) + + (stack.hashCode) + + (thumbhash.hashCode) + + (visibility.hashCode); + + @override + String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]'; + + Map<String, dynamic> toJson() { + final json = <String, dynamic>{}; + json[r'city'] = this.city; + json[r'country'] = this.country; + json[r'duration'] = this.duration; + json[r'id'] = this.id; + json[r'isFavorite'] = this.isFavorite; + json[r'isImage'] = this.isImage; + json[r'isTrashed'] = this.isTrashed; + json[r'livePhotoVideoId'] = this.livePhotoVideoId; + json[r'localDateTime'] = this.localDateTime; + json[r'ownerId'] = this.ownerId; + json[r'projectionType'] = this.projectionType; + json[r'ratio'] = this.ratio; + json[r'stack'] = this.stack; + json[r'thumbhash'] = this.thumbhash; + json[r'visibility'] = this.visibility; + return json; + } + + /// Returns a new [TimeBucketAssetResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TimeBucketAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketAssetResponseDto"); + if (value is Map) { + final json = value.cast<String, dynamic>(); + + return TimeBucketAssetResponseDto( + city: json[r'city'] is Iterable + ? (json[r'city'] as Iterable).cast<String>().toList(growable: false) + : const [], + country: json[r'country'] is Iterable + ? (json[r'country'] as Iterable).cast<String>().toList(growable: false) + : const [], + duration: json[r'duration'] is Iterable + ? (json[r'duration'] as Iterable).cast<String>().toList(growable: false) + : const [], + id: json[r'id'] is Iterable + ? (json[r'id'] as Iterable).cast<String>().toList(growable: false) + : const [], + isFavorite: json[r'isFavorite'] is Iterable + ? (json[r'isFavorite'] as Iterable).cast<bool>().toList(growable: false) + : const [], + isImage: json[r'isImage'] is Iterable + ? (json[r'isImage'] as Iterable).cast<bool>().toList(growable: false) + : const [], + isTrashed: json[r'isTrashed'] is Iterable + ? (json[r'isTrashed'] as Iterable).cast<bool>().toList(growable: false) + : const [], + livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable + ? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false) + : const [], + localDateTime: json[r'localDateTime'] is Iterable + ? (json[r'localDateTime'] as Iterable).cast<String>().toList(growable: false) + : const [], + ownerId: json[r'ownerId'] is Iterable + ? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false) + : const [], + projectionType: json[r'projectionType'] is Iterable + ? (json[r'projectionType'] as Iterable).cast<String>().toList(growable: false) + : const [], + ratio: json[r'ratio'] is Iterable + ? (json[r'ratio'] as Iterable).cast<num>().toList(growable: false) + : const [], + stack: json[r'stack'] is List + ? (json[r'stack'] as List).map((e) => + e == null ? null : (e as List).cast<String>() + ).toList() + : const [], + thumbhash: json[r'thumbhash'] is Iterable + ? (json[r'thumbhash'] as Iterable).cast<String>().toList(growable: false) + : const [], + visibility: AssetVisibility.listFromJson(json[r'visibility']), + ); + } + return null; + } + + static List<TimeBucketAssetResponseDto> listFromJson(dynamic json, {bool growable = false,}) { + final result = <TimeBucketAssetResponseDto>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TimeBucketAssetResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map<String, TimeBucketAssetResponseDto> mapFromJson(dynamic json) { + final map = <String, TimeBucketAssetResponseDto>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TimeBucketAssetResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TimeBucketAssetResponseDto-objects as value to a dart map + static Map<String, List<TimeBucketAssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<TimeBucketAssetResponseDto>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + for (final entry in json.entries) { + map[entry.key] = TimeBucketAssetResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = <String>{ + 'city', + 'country', + 'duration', + 'id', + 'isFavorite', + 'isImage', + 'isTrashed', + 'livePhotoVideoId', + 'localDateTime', + 'ownerId', + 'projectionType', + 'ratio', + 'thumbhash', + 'visibility', + }; +} + diff --git a/mobile/openapi/lib/model/time_bucket_size.dart b/mobile/openapi/lib/model/time_bucket_size.dart deleted file mode 100644 index e843b43f43..0000000000 --- a/mobile/openapi/lib/model/time_bucket_size.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class TimeBucketSize { - /// Instantiate a new enum with the provided [value]. - const TimeBucketSize._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const DAY = TimeBucketSize._(r'DAY'); - static const MONTH = TimeBucketSize._(r'MONTH'); - - /// List of all possible values in this [enum][TimeBucketSize]. - static const values = <TimeBucketSize>[ - DAY, - MONTH, - ]; - - static TimeBucketSize? fromJson(dynamic value) => TimeBucketSizeTypeTransformer().decode(value); - - static List<TimeBucketSize> listFromJson(dynamic json, {bool growable = false,}) { - final result = <TimeBucketSize>[]; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = TimeBucketSize.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [TimeBucketSize] to String, -/// and [decode] dynamic data back to [TimeBucketSize]. -class TimeBucketSizeTypeTransformer { - factory TimeBucketSizeTypeTransformer() => _instance ??= const TimeBucketSizeTypeTransformer._(); - - const TimeBucketSizeTypeTransformer._(); - - String encode(TimeBucketSize data) => data.value; - - /// Decodes a [dynamic value][data] to a TimeBucketSize. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - TimeBucketSize? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'DAY': return TimeBucketSize.DAY; - case r'MONTH': return TimeBucketSize.MONTH; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [TimeBucketSizeTypeTransformer] instance. - static TimeBucketSizeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_buckets_response_dto.dart similarity index 62% rename from mobile/openapi/lib/model/time_bucket_response_dto.dart rename to mobile/openapi/lib/model/time_buckets_response_dto.dart index 56044b27a8..8c9f8dab61 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_buckets_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class TimeBucketResponseDto { - /// Returns a new [TimeBucketResponseDto] instance. - TimeBucketResponseDto({ +class TimeBucketsResponseDto { + /// Returns a new [TimeBucketsResponseDto] instance. + TimeBucketsResponseDto({ required this.count, required this.timeBucket, }); @@ -22,7 +22,7 @@ class TimeBucketResponseDto { String timeBucket; @override - bool operator ==(Object other) => identical(this, other) || other is TimeBucketResponseDto && + bool operator ==(Object other) => identical(this, other) || other is TimeBucketsResponseDto && other.count == count && other.timeBucket == timeBucket; @@ -33,7 +33,7 @@ class TimeBucketResponseDto { (timeBucket.hashCode); @override - String toString() => 'TimeBucketResponseDto[count=$count, timeBucket=$timeBucket]'; + String toString() => 'TimeBucketsResponseDto[count=$count, timeBucket=$timeBucket]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -42,15 +42,15 @@ class TimeBucketResponseDto { return json; } - /// Returns a new [TimeBucketResponseDto] instance and imports its values from + /// Returns a new [TimeBucketsResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static TimeBucketResponseDto? fromJson(dynamic value) { - upgradeDto(value, "TimeBucketResponseDto"); + static TimeBucketsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketsResponseDto"); if (value is Map) { final json = value.cast<String, dynamic>(); - return TimeBucketResponseDto( + return TimeBucketsResponseDto( count: mapValueOfType<int>(json, r'count')!, timeBucket: mapValueOfType<String>(json, r'timeBucket')!, ); @@ -58,11 +58,11 @@ class TimeBucketResponseDto { return null; } - static List<TimeBucketResponseDto> listFromJson(dynamic json, {bool growable = false,}) { - final result = <TimeBucketResponseDto>[]; + static List<TimeBucketsResponseDto> listFromJson(dynamic json, {bool growable = false,}) { + final result = <TimeBucketsResponseDto>[]; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = TimeBucketResponseDto.fromJson(row); + final value = TimeBucketsResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -71,12 +71,12 @@ class TimeBucketResponseDto { return result.toList(growable: growable); } - static Map<String, TimeBucketResponseDto> mapFromJson(dynamic json) { - final map = <String, TimeBucketResponseDto>{}; + static Map<String, TimeBucketsResponseDto> mapFromJson(dynamic json) { + final map = <String, TimeBucketsResponseDto>{}; if (json is Map && json.isNotEmpty) { json = json.cast<String, dynamic>(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = TimeBucketResponseDto.fromJson(entry.value); + final value = TimeBucketsResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -85,14 +85,14 @@ class TimeBucketResponseDto { return map; } - // maps a json object with a list of TimeBucketResponseDto-objects as value to a dart map - static Map<String, List<TimeBucketResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = <String, List<TimeBucketResponseDto>>{}; + // maps a json object with a list of TimeBucketsResponseDto-objects as value to a dart map + static Map<String, List<TimeBucketsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<TimeBucketsResponseDto>>{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast<String, dynamic>(); for (final entry in json.entries) { - map[entry.key] = TimeBucketResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = TimeBucketsResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index e2badc6dff..d6f1333489 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -OPENAPI_GENERATOR_VERSION=v7.8.0 +OPENAPI_GENERATOR_VERSION=v7.12.0 # usage: ./bin/generate-open-api.sh @@ -8,6 +8,7 @@ function dart { cd ./templates/mobile/serialization/native wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch + patch --no-backup-if-mismatch -u native_class.mustache <native_class_nullable_items_in_arrays.patch cd ../../ wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5de3987367..8d21c3ef90 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7284,6 +7284,24 @@ "$ref": "#/components/schemas/AssetOrder" } }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "type": "number" + } + }, + { + "name": "pageSize", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "type": "number" + } + }, { "name": "personId", "required": false, @@ -7293,14 +7311,6 @@ "type": "string" } }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, { "name": "tagId", "required": false, @@ -7357,10 +7367,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/TimeBucketAssetResponseDto" } } }, @@ -7437,14 +7444,6 @@ "type": "string" } }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, { "name": "tagId", "required": false, @@ -7494,7 +7493,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/TimeBucketResponseDto" + "$ref": "#/components/schemas/TimeBucketsResponseDto" }, "type": "array" } @@ -14069,7 +14068,131 @@ ], "type": "object" }, - "TimeBucketResponseDto": { + "TimeBucketAssetResponseDto": { + "properties": { + "city": { + "items": { + "nullable": true, + "type": "string" + }, + "type": "array" + }, + "country": { + "items": { + "nullable": true, + "type": "string" + }, + "type": "array" + }, + "duration": { + "items": { + "nullable": true, + "type": "string" + }, + "type": "array" + }, + "id": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isFavorite": { + "items": { + "type": "boolean" + }, + "type": "array" + }, + "isImage": { + "items": { + "type": "boolean" + }, + "type": "array" + }, + "isTrashed": { + "items": { + "type": "boolean" + }, + "type": "array" + }, + "livePhotoVideoId": { + "items": { + "nullable": true, + "type": "string" + }, + "type": "array" + }, + "localDateTime": { + "items": { + "type": "string" + }, + "type": "array" + }, + "ownerId": { + "items": { + "type": "string" + }, + "type": "array" + }, + "projectionType": { + "items": { + "nullable": true, + "type": "string" + }, + "type": "array" + }, + "ratio": { + "items": { + "type": "number" + }, + "type": "array" + }, + "stack": { + "description": "(stack ID, stack asset count) tuple", + "items": { + "items": { + "type": "string" + }, + "maxItems": 2, + "minItems": 2, + "nullable": true, + "type": "array" + }, + "type": "array" + }, + "thumbhash": { + "items": { + "nullable": true, + "type": "string" + }, + "type": "array" + }, + "visibility": { + "items": { + "$ref": "#/components/schemas/AssetVisibility" + }, + "type": "array" + } + }, + "required": [ + "city", + "country", + "duration", + "id", + "isFavorite", + "isImage", + "isTrashed", + "livePhotoVideoId", + "localDateTime", + "ownerId", + "projectionType", + "ratio", + "thumbhash", + "visibility" + ], + "type": "object" + }, + "TimeBucketsResponseDto": { "properties": { "count": { "type": "integer" @@ -14084,13 +14207,6 @@ ], "type": "object" }, - "TimeBucketSize": { - "enum": [ - "DAY", - "MONTH" - ], - "type": "string" - }, "ToneMapping": { "enum": [ "hable", diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 9a7b1439b1..9f40d5b0bf 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -32,7 +32,7 @@ class {{{classname}}} { {{/required}} {{/isNullable}} {{/isEnum}} - {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; + {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; {{/vars}} @override diff --git a/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch b/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch new file mode 100644 index 0000000000..a59e300913 --- /dev/null +++ b/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch @@ -0,0 +1,13 @@ +diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache +index 9a7b1439b..9f40d5b0b 100644 +--- a/open-api/templates/mobile/serialization/native/native_class.mustache ++++ b/open-api/templates/mobile/serialization/native/native_class.mustache +@@ -32,7 +32,7 @@ class {{{classname}}} { + {{/required}} + {{/isNullable}} + {{/isEnum}} +- {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; ++ {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; + + {{/vars}} + @override diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c293b2aa6c..5358cdfec9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1420,7 +1420,25 @@ export type TagBulkAssetsResponseDto = { export type TagUpdateDto = { color?: string | null; }; -export type TimeBucketResponseDto = { +export type TimeBucketAssetResponseDto = { + city: (string | null)[]; + country: (string | null)[]; + duration: (string | null)[]; + id: string[]; + isFavorite: boolean[]; + isImage: boolean[]; + isTrashed: boolean[]; + livePhotoVideoId: (string | null)[]; + localDateTime: string[]; + ownerId: string[]; + projectionType: (string | null)[]; + ratio: number[]; + /** (stack ID, stack asset count) tuple */ + stack?: (string[] | null)[]; + thumbhash: (string | null)[]; + visibility: AssetVisibility[]; +}; +export type TimeBucketsResponseDto = { count: number; timeBucket: string; }; @@ -3367,14 +3385,15 @@ export function tagAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; + page?: number; + pageSize?: number; personId?: string; - size: TimeBucketSize; tagId?: string; timeBucket: string; userId?: string; @@ -3384,15 +3403,16 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; + data: TimeBucketAssetResponseDto; }>(`/timeline/bucket${QS.query(QS.explode({ albumId, isFavorite, isTrashed, key, order, + page, + pageSize, personId, - size, tagId, timeBucket, userId, @@ -3403,14 +3423,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers ...opts })); } -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, tagId, userId, visibility, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; personId?: string; - size: TimeBucketSize; tagId?: string; userId?: string; visibility?: AssetVisibility; @@ -3419,7 +3438,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TimeBucketResponseDto[]; + data: TimeBucketsResponseDto[]; }>(`/timeline/buckets${QS.query(QS.explode({ albumId, isFavorite, @@ -3427,7 +3446,6 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per key, order, personId, - size, tagId, userId, visibility, @@ -3921,7 +3939,3 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } -export enum TimeBucketSize { - Day = "DAY", - Month = "MONTH" -} diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index b791358a90..a114830e09 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -72,7 +72,9 @@ class SqlGenerator { await rm(this.options.targetDir, { force: true, recursive: true }); await mkdir(this.options.targetDir); - process.env.DB_HOSTNAME = 'localhost'; + if (!process.env.DB_HOSTNAME) { + process.env.DB_HOSTNAME = 'localhost'; + } const { database, cls, otel } = new ConfigRepository().getEnv(); const moduleFixture = await Test.createTestingModule({ diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 92de84d346..b4ee042625 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -1,8 +1,7 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { Controller, Get, Header, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TimelineService } from 'src/services/timeline.service'; @@ -14,13 +13,15 @@ export class TimelineController { @Get('buckets') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> { + getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) { return this.service.getTimeBuckets(auth, dto); } @Get('bucket') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> { - return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>; + @ApiOkResponse({ type: TimeBucketAssetResponseDto }) + @Header('Content-Type', 'application/json') + getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { + return this.service.getTimeBucket(auth, dto); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 2a44a34b58..4c1f2571e8 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -13,6 +13,7 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -140,15 +141,6 @@ const mapStack = (entity: { stack?: Stack | null }) => { }; }; -// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings -export const hexOrBufferToBase64 = (encoded: string | Buffer) => { - if (typeof encoded === 'string') { - return Buffer.from(encoded.slice(2), 'hex').toString('base64'); - } - - return encoded.toString('base64'); -}; - export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -192,7 +184,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), - checksum: hexOrBufferToBase64(entity.checksum), + checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 51d46871ae..f68ce93075 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,15 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; + +import { IsEnum, IsInt, IsString, Min } from 'class-validator'; import { AssetOrder, AssetVisibility } from 'src/enum'; -import { TimeBucketSize } from 'src/repositories/asset.repository'; import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { - @IsNotEmpty() - @IsEnum(TimeBucketSize) - @ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' }) - size!: TimeBucketSize; - @ValidateUUID({ optional: true }) userId?: string; @@ -46,9 +41,75 @@ export class TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto { @IsString() timeBucket!: string; + + @IsInt() + @Min(1) + @Optional() + page?: number; + + @IsInt() + @Min(1) + @Optional() + pageSize?: number; } -export class TimeBucketResponseDto { +export class TimelineStackResponseDto { + id!: string; + primaryAssetId!: string; + assetCount!: number; +} + +export class TimeBucketAssetResponseDto { + id!: string[]; + + ownerId!: string[]; + + ratio!: number[]; + + isFavorite!: boolean[]; + + @ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true }) + visibility!: AssetVisibility[]; + + isTrashed!: boolean[]; + + isImage!: boolean[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + thumbhash!: (string | null)[]; + + localDateTime!: string[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + duration!: (string | null)[]; + + @ApiProperty({ + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + minItems: 2, + maxItems: 2, + nullable: true, + }, + description: '(stack ID, stack asset count) tuple', + }) + stack?: ([string, string] | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + projectionType!: (string | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + livePhotoVideoId!: (string | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + city!: (string | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + country!: (string | null)[]; +} + +export class TimeBucketsResponseDto { @ApiProperty({ type: 'string' }) timeBucket!: string; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f4f13c4d2b..8f25cbbd4a 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -235,14 +235,14 @@ limit with "assets" as ( select - date_trunc($1, "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket" + date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket" from "assets" where "assets"."deletedAt" is null and ( - "assets"."visibility" = $2 - or "assets"."visibility" = $3 + "assets"."visibility" = $1 + or "assets"."visibility" = $2 ) ) select @@ -256,40 +256,101 @@ order by "timeBucket" desc -- AssetRepository.getTimeBucket -select - "assets".*, - to_json("exif") as "exifInfo", - to_json("stacked_assets") as "stack" -from - "assets" - left join "exif" on "assets"."id" = "exif"."assetId" - left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" - left join lateral ( +with + "cte" as ( select - "asset_stack".*, - count("stacked") as "assetCount" + "assets"."duration", + "assets"."id", + "assets"."visibility", + "assets"."isFavorite", + assets.type = 'IMAGE' as "isImage", + assets."deletedAt" is null as "isTrashed", + "assets"."livePhotoVideoId", + "assets"."localDateTime", + "assets"."ownerId", + "assets"."status", + encode("assets"."thumbhash", 'base64') as "thumbhash", + "exif"."city", + "exif"."country", + "exif"."projectionType", + coalesce( + case + when exif."exifImageHeight" = 0 + or exif."exifImageWidth" = 0 then 1 + when "exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round( + exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, + 3 + ) + else round( + exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, + 3 + ) + end, + 1 + ) as "ratio", + "stack" from - "assets" as "stacked" + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" + left join lateral ( + select + array[stacked."stackId"::text, count('stacked')::text] as "stack" + from + "assets" as "stacked" + where + "stacked"."stackId" = "assets"."stackId" + and "stacked"."deletedAt" is null + and "stacked"."visibility" != $1 + group by + "stacked"."stackId" + ) as "stacked_assets" on true where - "stacked"."stackId" = "asset_stack"."id" - and "stacked"."deletedAt" is null - and "stacked"."visibility" != $1 - group by - "asset_stack"."id" - ) as "stacked_assets" on "asset_stack"."id" is not null -where - ( - "asset_stack"."primaryAssetId" = "assets"."id" - or "assets"."stackId" is null + "assets"."deletedAt" is null + and ( + "assets"."visibility" = $2 + or "assets"."visibility" = $3 + ) + and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4 + and ( + "assets"."visibility" = $5 + or "assets"."visibility" = $6 + ) + and not exists ( + select + from + "asset_stack" + where + "asset_stack"."id" = "assets"."stackId" + and "asset_stack"."primaryAssetId" != "assets"."id" + ) + order by + "assets"."localDateTime" desc + ), + "agg" as ( + select + coalesce(array_agg("city"), '{}') as "city", + coalesce(array_agg("country"), '{}') as "country", + coalesce(array_agg("duration"), '{}') as "duration", + coalesce(array_agg("id"), '{}') as "id", + coalesce(array_agg("visibility"), '{}') as "visibility", + coalesce(array_agg("isFavorite"), '{}') as "isFavorite", + coalesce(array_agg("isImage"), '{}') as "isImage", + coalesce(array_agg("isTrashed"), '{}') as "isTrashed", + coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId", + coalesce(array_agg("localDateTime"), '{}') as "localDateTime", + coalesce(array_agg("ownerId"), '{}') as "ownerId", + coalesce(array_agg("projectionType"), '{}') as "projectionType", + coalesce(array_agg("ratio"), '{}') as "ratio", + coalesce(array_agg("status"), '{}') as "status", + coalesce(array_agg("thumbhash"), '{}') as "thumbhash", + coalesce(json_agg("stack"), '[]') as "stack" + from + "cte" ) - and "assets"."deletedAt" is null - and ( - "assets"."visibility" = $2 - or "assets"."visibility" = $3 - ) - and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5 -order by - "assets"."localDateTime" desc +select + to_json(agg)::text as "assets" +from + "agg" -- AssetRepository.getDuplicates with diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e118bf39ad..f2f323f71e 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -68,7 +68,6 @@ export interface AssetBuilderOptions { } export interface TimeBucketOptions extends AssetBuilderOptions { - size: TimeBucketSize; order?: AssetOrder; } @@ -539,7 +538,7 @@ export class AssetRepository { .with('assets', (qb) => qb .selectFrom('assets') - .select(truncatedDate<Date>(options.size).as('timeBucket')) + .select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility) @@ -581,53 +580,126 @@ export class AssetRepository { ); } - @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) - async getTimeBucket(timeBucket: string, options: TimeBucketOptions) { - return this.db - .selectFrom('assets') - .selectAll('assets') - .$call(withExif) - .$if(!!options.albumId, (qb) => + @GenerateSql({ + params: [DummyValue.TIME_BUCKET, { withStacked: true }], + }) + getTimeBucket(timeBucket: string, options: TimeBucketOptions) { + const query = this.db + .with('cte', (qb) => qb - .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') - .where('albums_assets_assets.albumsId', '=', options.albumId!), + .selectFrom('assets') + .innerJoin('exif', 'assets.id', 'exif.assetId') + .select((eb) => [ + 'assets.duration', + 'assets.id', + 'assets.visibility', + 'assets.isFavorite', + sql`assets.type = 'IMAGE'`.as('isImage'), + sql`assets."deletedAt" is null`.as('isTrashed'), + 'assets.livePhotoVideoId', + 'assets.localDateTime', + 'assets.ownerId', + 'assets.status', + eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'), + 'exif.city', + 'exif.country', + 'exif.projectionType', + eb.fn + .coalesce( + eb + .case() + .when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`) + .then(eb.lit(1)) + .when('exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`) + .then(sql`round(exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, 3)`) + .else(sql`round(exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, 3)`) + .end(), + eb.lit(1), + ) + .as('ratio'), + ]) + .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .$if(options.visibility == undefined, withDefaultVisibility) + .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) + .where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, '')) + .$if(!!options.albumId, (qb) => + qb.where((eb) => + eb.exists( + eb + .selectFrom('albums_assets_assets') + .whereRef('albums_assets_assets.assetsId', '=', 'assets.id') + .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), + ), + ), + ) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(options.visibility == undefined, withDefaultVisibility) + .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) + .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(!!options.withStacked, (qb) => + qb + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_stack') + .whereRef('asset_stack.id', '=', 'assets.stackId') + .whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'), + ), + ), + ) + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack')) + .whereRef('stacked.stackId', '=', 'assets.stackId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) + .groupBy('stacked.stackId') + .as('stacked_assets'), + (join) => join.onTrue(), + ) + .select('stack'), + ) + .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) + .$if(options.isDuplicate !== undefined, (qb) => + qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), + ) + .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) + .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) + .orderBy('assets.localDateTime', options.order ?? 'desc'), ) - .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) - .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) - .$if(!!options.withStacked, (qb) => + .with('agg', (qb) => qb - .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') - .where((eb) => - eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]), - ) - .leftJoinLateral( - (eb) => - eb - .selectFrom('assets as stacked') - .selectAll('asset_stack') - .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) - .whereRef('stacked.stackId', '=', 'asset_stack.id') - .where('stacked.deletedAt', 'is', null) - .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) - .groupBy('asset_stack.id') - .as('stacked_assets'), - (join) => join.on('asset_stack.id', 'is not', null), - ) - .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')), + .selectFrom('cte') + .select((eb) => [ + eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'), + eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'), + eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'), + eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'), + eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'), + eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'), + eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'), + // TODO: isTrashed is redundant as it will always be all true or false depending on the options + eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'), + eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'), + eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'), + eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'), + eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'), + eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'), + eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'), + eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'), + ]) + .$if(!!options.withStacked, (qb) => + qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')), + ), ) - .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) - .$if(options.isDuplicate !== undefined, (qb) => - qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), - ) - .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) - .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) - .$if(options.visibility == undefined, withDefaultVisibility) - .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) - .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, '')) - .orderBy('assets.localDateTime', options.order ?? 'desc') - .execute(); + .selectFrom('agg') + .select(sql<string>`to_json(agg)::text`.as('assets')); + + return query.executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 6ad488c48d..bd3c09098f 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon'; import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { SessionSyncCheckpoints } from 'src/db'; -import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, @@ -18,6 +18,7 @@ import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType import { BaseService } from 'src/services/base.service'; import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; import { fromAck, serialize } from 'src/utils/sync'; diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 1447594d4e..1669b1eac7 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,10 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { AssetVisibility } from 'src/enum'; -import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimelineService } from 'src/services/timeline.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { @@ -19,13 +16,10 @@ describe(TimelineService.name, () => { it("should return buckets if userId and albumId aren't set", async () => { mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); - await expect( - sut.getTimeBuckets(authStub.admin, { - size: TimeBucketSize.DAY, - }), - ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual( + expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]), + ); expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ - size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id], }); }); @@ -34,35 +28,34 @@ describe(TimelineService.name, () => { describe('getTimeBucket', () => { it('should return the assets for a album time bucket if user has album.read', async () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); - await expect( - sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual( + json, + ); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id', }); }); it('should return the assets for a archive time bucket if user has archive.read', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.ARCHIVE, userId: authStub.admin.user.id, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.ARCHIVE, userIds: [authStub.admin.user.id], @@ -71,20 +64,19 @@ describe(TimelineService.name, () => { }); it('should include partner shared assets', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); mocks.partner.getAll.mockResolvedValue([]); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.TIMELINE, userId: authStub.admin.user.id, withPartners: true, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.TIMELINE, withPartners: true, @@ -93,62 +85,37 @@ describe(TimelineService.name, () => { }); it('should check permissions to read tag', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', userId: authStub.admin.user.id, tagId: 'tag-123', }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, tagId: 'tag-123', timeBucket: 'bucket', userIds: [authStub.admin.user.id], }); }); - it('should strip metadata if showExif is disabled', async () => { - mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); - - const auth = factory.auth({ sharedLink: { showExif: false } }); - - const buckets = await sut.getTimeBucket(auth, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, - albumId: 'album-id', - }); - - expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); - expect(buckets[0]).not.toHaveProperty('exif'); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, - albumId: 'album-id', - }); - }); - it('should return the assets for a library time bucket if user has library.read', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', userId: authStub.admin.user.id, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ - size: TimeBucketSize.DAY, timeBucket: 'bucket', userIds: [authStub.admin.user.id], }), @@ -158,7 +125,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and visibility true or undefined', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.ARCHIVE, withPartners: true, @@ -168,7 +134,6 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: undefined, withPartners: true, @@ -180,7 +145,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and isFavorite is either true or false', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isFavorite: true, withPartners: true, @@ -190,7 +154,6 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isFavorite: false, withPartners: true, @@ -202,7 +165,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and isTrash is true', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isTrashed: true, withPartners: true, diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index c0cd4786a8..f3ebcc2cd7 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto'; import { AssetVisibility, Permission } from 'src/enum'; import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; @@ -9,22 +8,20 @@ import { getMyPartnerIds } from 'src/utils/asset.util'; @Injectable() export class TimelineService extends BaseService { - async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> { + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - return this.assetRepository.getTimeBuckets(timeBucketOptions); + return await this.assetRepository.getTimeBuckets(timeBucketOptions); } - async getTimeBucket( - auth: AuthDto, - dto: TimeBucketAssetDto, - ): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> { + // pre-jsonified response + async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> { await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); - return !auth.sharedLink || auth.sharedLink?.showExif - ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) - : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto }); + + // TODO: use id cursor for pagination + const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); + return bucket.assets; } private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> { diff --git a/server/src/utils/bytes.ts b/server/src/utils/bytes.ts index e837c81b9e..5e476f4dea 100644 --- a/server/src/utils/bytes.ts +++ b/server/src/utils/bytes.ts @@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string { return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`; } + +// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings +export const hexOrBufferToBase64 = (encoded: string | Buffer) => { + if (typeof encoded === 'string') { + return Buffer.from(encoded.slice(2), 'hex').toString('base64'); + } + + return encoded.toString('base64'); +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index bacdf06d67..e0e7af49a4 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -271,7 +271,7 @@ export function withTags(eb: ExpressionBuilder<DB, 'assets'>) { } export function truncatedDate<O>(size: TimeBucketSize) { - return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; + return sql<O>`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; } export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) { @@ -285,6 +285,7 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: str ), ); } + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 4248b23d30..ce1520c475 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -14,7 +14,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { ApiService } from 'src/services/api.service'; import { isStartUpError, useSwagger } from 'src/utils/misc'; - async function bootstrap() { process.title = 'immich-api'; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index a64194361a..454be00844 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -251,6 +251,10 @@ export const assetStub = { duplicateId: null, isOffline: false, stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, visibility: AssetVisibility.TIMELINE, }), diff --git a/typescript-open-api/typescript-sdk/package-lock.json b/typescript-open-api/typescript-sdk/package-lock.json new file mode 100644 index 0000000000..ca6fc5e1de --- /dev/null +++ b/typescript-open-api/typescript-sdk/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "typescript-sdk", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte index 80dfb35067..be5e8f7827 100644 --- a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte +++ b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import { AssetAction } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import { keepThisDeleteOthers } from '$lib/utils/asset-utils'; diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte index d133010af7..91db84b172 100644 --- a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte @@ -5,7 +5,7 @@ import { modalManager } from '$lib/managers/modal-manager.svelte'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { handleError } from '$lib/utils/handle-error'; - import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk'; + import { AssetVisibility, updateAssets } from '@immich/sdk'; import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { OnAction, PreAction } from './action'; @@ -17,7 +17,7 @@ } let { asset, onAction, preAction }: Props = $props(); - const isLocked = asset.visibility === Visibility.Locked; + const isLocked = asset.visibility === AssetVisibility.Locked; const toggleLockedVisibility = async () => { const isConfirmed = await modalManager.showDialog({ diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index c3df91623b..eb8b15c4b5 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -5,7 +5,7 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetMediaSize, Visibility } from '@immich/sdk'; + import { AssetMediaSize, AssetVisibility } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiCameraBurst, @@ -291,7 +291,7 @@ </div> {/if} - {#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive} + {#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive} <div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}> <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" /> </div> diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte index f42496fab4..8e8311a2ed 100644 --- a/web/src/lib/components/photos-page/actions/archive-action.svelte +++ b/web/src/lib/components/photos-page/actions/archive-action.svelte @@ -2,7 +2,7 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import type { OnArchive } from '$lib/utils/actions'; import { archiveAssets } from '$lib/utils/asset-utils'; - import { AssetVisibility, Visibility } from '@immich/sdk'; + import { AssetVisibility } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js'; import { t } from 'svelte-i18n'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; @@ -24,12 +24,12 @@ const { clearSelect, getOwnedAssets } = getAssetControlContext(); const handleArchive = async () => { - const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive; + const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive; const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived); loading = true; - const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility); + const ids = await archiveAssets(assets, isArchived as AssetVisibility); if (ids) { - onArchive?.(ids, isArchived); + onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline); clearSelect(); } loading = false; diff --git a/web/src/lib/stores/asset-interaction.svelte.spec.ts b/web/src/lib/stores/asset-interaction.svelte.spec.ts index 2469c39b55..86e859f57e 100644 --- a/web/src/lib/stores/asset-interaction.svelte.spec.ts +++ b/web/src/lib/stores/asset-interaction.svelte.spec.ts @@ -1,6 +1,6 @@ import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { resetSavedUser, user } from '$lib/stores/user.store'; -import { Visibility } from '@immich/sdk'; +import { AssetVisibility } from '@immich/sdk'; import { timelineAssetFactory } from '@test-data/factories/asset-factory'; import { userAdminFactory } from '@test-data/factories/user-factory'; @@ -13,10 +13,10 @@ describe('AssetInteraction', () => { it('calculates derived values from selection', () => { assetInteraction.selectAsset( - timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }), + timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Archive, isTrashed: true }), ); assetInteraction.selectAsset( - timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }), + timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Timeline, isTrashed: false }), ); expect(assetInteraction.selectionActive).toBe(true); diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts index 48dc958893..41ce8c8d27 100644 --- a/web/src/lib/stores/asset-interaction.svelte.ts +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -1,6 +1,6 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { user } from '$lib/stores/user.store'; -import { Visibility, type UserAdminResponseDto } from '@immich/sdk'; +import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk'; import { SvelteSet } from 'svelte/reactivity'; import { fromStore } from 'svelte/store'; @@ -21,7 +21,7 @@ export class AssetInteraction { private userId = $derived(this.user.current?.id); isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed)); - isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive)); + isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts index e510feeb6c..7864c9618b 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -1,8 +1,8 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AbortError } from '$lib/utils'; -import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; -import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory'; -import { AssetStore } from './assets-store.svelte'; +import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; +import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; +import { AssetStore, type TimelineAsset } from './assets-store.svelte'; describe('AssetStore', () => { beforeEach(() => { @@ -11,18 +11,22 @@ describe('AssetStore', () => { describe('init', () => { let assetStore: AssetStore; - const bucketAssets: Record<string, AssetResponseDto[]> = { - '2024-03-01T00:00:00.000Z': assetFactory + const bucketAssets: Record<string, TimelineAsset[]> = { + '2024-03-01T00:00:00.000Z': timelineAssetFactory .buildList(1) .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), - '2024-02-01T00:00:00.000Z': assetFactory + '2024-02-01T00:00:00.000Z': timelineAssetFactory .buildList(100) .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), - '2024-01-01T00:00:00.000Z': assetFactory + '2024-01-01T00:00:00.000Z': timelineAssetFactory .buildList(3) .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; + const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries( + Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), + ); + beforeEach(async () => { assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ @@ -30,13 +34,14 @@ describe('AssetStore', () => { { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, ]); - sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); + + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('should load buckets in viewport', () => { expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); - expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month }); + expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); }); @@ -48,29 +53,31 @@ describe('AssetStore', () => { expect(plainBuckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(5103.333_333_333_333); + expect(assetStore.timelineHeight).toBe(12_487.5); }); }); describe('loadBucket', () => { let assetStore: AssetStore; - const bucketAssets: Record<string, AssetResponseDto[]> = { - '2024-01-03T00:00:00.000Z': assetFactory + const bucketAssets: Record<string, TimelineAsset[]> = { + '2024-01-03T00:00:00.000Z': timelineAssetFactory .buildList(1) .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), - '2024-01-01T00:00:00.000Z': assetFactory + '2024-01-01T00:00:00.000Z': timelineAssetFactory .buildList(3) .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; - + const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries( + Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), + ); beforeEach(async () => { assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ @@ -82,7 +89,7 @@ describe('AssetStore', () => { if (signal?.aborted) { throw new AbortError(); } - return bucketAssets[timeBucket]; + return bucketAssetsResponse[timeBucket]; }); await assetStore.updateViewport({ width: 1588, height: 0 }); }); @@ -296,7 +303,9 @@ describe('AssetStore', () => { }); it('removes asset from bucket', () => { - const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); + const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { + localDateTime: '2024-01-20T12:00:00.000Z', + }); assetStore.addAssets([assetOne, assetTwo]); assetStore.removeAssets([assetOne.id]); @@ -342,17 +351,20 @@ describe('AssetStore', () => { describe('getPreviousAsset', () => { let assetStore: AssetStore; - const bucketAssets: Record<string, AssetResponseDto[]> = { - '2024-03-01T00:00:00.000Z': assetFactory + const bucketAssets: Record<string, TimelineAsset[]> = { + '2024-03-01T00:00:00.000Z': timelineAssetFactory .buildList(1) .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), - '2024-02-01T00:00:00.000Z': assetFactory + '2024-02-01T00:00:00.000Z': timelineAssetFactory .buildList(6) .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), - '2024-01-01T00:00:00.000Z': assetFactory + '2024-01-01T00:00:00.000Z': timelineAssetFactory .buildList(3) .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; + const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries( + Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), + ); beforeEach(async () => { assetStore = new AssetStore(); @@ -361,8 +373,7 @@ describe('AssetStore', () => { { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, ]); - sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); await assetStore.updateViewport({ width: 1588, height: 1000 }); }); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index 8093c4b583..10bb52a1ab 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -1,4 +1,5 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; + import { locale } from '$lib/stores/preferences.store'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { @@ -15,10 +16,8 @@ import { getAssetInfo, getTimeBucket, getTimeBuckets, - TimeBucketSize, - Visibility, - type AssetResponseDto, type AssetStackResponseDto, + type TimeBucketAssetResponseDto, } from '@immich/sdk'; import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { DateTime } from 'luxon'; @@ -32,6 +31,7 @@ const { } = TUNABLES; type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0]; + export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & { timelineAlbumId?: string; deferInit?: boolean; @@ -75,7 +75,7 @@ export type TimelineAsset = { ratio: number; thumbhash: string | null; localDateTime: string; - visibility: Visibility; + visibility: AssetVisibility; isFavorite: boolean; isTrashed: boolean; isVideo: boolean; @@ -84,12 +84,11 @@ export type TimelineAsset = { duration: string | null; projectionType: string | null; livePhotoVideoId: string | null; - text: { - city: string | null; - country: string | null; - people: string[]; - }; + city: string | null; + country: string | null; + people: string[]; }; + class IntersectingAsset { // --- public --- readonly #group: AssetDateGroup; @@ -113,7 +112,7 @@ class IntersectingAsset { }); position: CommonPosition | undefined = $state(); - asset: TimelineAsset | undefined = $state(); + asset: TimelineAsset = <TimelineAsset>$state(); id: string | undefined = $derived(this.asset?.id); constructor(group: AssetDateGroup, asset: TimelineAsset) { @@ -121,9 +120,11 @@ class IntersectingAsset { this.asset = asset; } } + type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; type MoveAsset = { asset: TimelineAsset; year: number; month: number }; + export class AssetDateGroup { // --- public readonly bucket: AssetBucket; @@ -166,6 +167,7 @@ export class AssetDateGroup { getFirstAsset() { return this.intersetingAssets[0]?.asset; } + getRandomAsset() { const random = Math.floor(Math.random() * this.intersetingAssets.length); return this.intersetingAssets[random]; @@ -243,6 +245,7 @@ export interface Viewport { width: number; height: number; } + export type ViewportXY = Viewport & { x: number; y: number; @@ -250,11 +253,46 @@ export type ViewportXY = Viewport & { class AddContext { lookupCache: { - [dayOfMonth: number]: AssetDateGroup; + [year: number]: { [month: number]: { [day: number]: AssetDateGroup } }; } = {}; unprocessedAssets: TimelineAsset[] = []; changedDateGroups = new Set<AssetDateGroup>(); newDateGroups = new Set<AssetDateGroup>(); + + getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined { + return this.lookupCache[year]?.[month]?.[day]; + } + + setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) { + if (!this.lookupCache[year]) { + this.lookupCache[year] = {}; + } + if (!this.lookupCache[year][month]) { + this.lookupCache[year][month] = {}; + } + this.lookupCache[year][month][day] = dateGroup; + } + + get existingDateGroups() { + return this.changedDateGroups.difference(this.newDateGroups); + } + + get updatedBuckets() { + const updated = new Set<AssetBucket>(); + for (const group of this.changedDateGroups) { + updated.add(group.bucket); + } + return updated; + } + + get bucketsWithNewDateGroups() { + const updated = new Set<AssetBucket>(); + for (const group of this.newDateGroups) { + updated.add(group.bucket); + } + return updated; + } + sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) { for (const group of this.changedDateGroups) { group.sortAssets(sortOrder); @@ -267,6 +305,7 @@ class AddContext { } } } + export class AssetBucket { // --- public --- #intersecting: boolean = $state(false); @@ -331,6 +370,7 @@ export class AssetBucket { this.handleLoadError, ); } + set intersecting(newValue: boolean) { const old = this.#intersecting; if (old !== newValue) { @@ -422,52 +462,74 @@ export class AssetBucket { }; } - // note - if the assets are not part of this bucket, they will not be added - addAssets(bucketResponse: AssetResponseDto[]) { + addAssets(bucketAssets: TimeBucketAssetResponseDto) { const addContext = new AddContext(); - for (const asset of bucketResponse) { - const timelineAsset = toTimelineAsset(asset); + const people: string[] = []; + for (let i = 0; i < bucketAssets.id.length; i++) { + const timelineAsset: TimelineAsset = { + city: bucketAssets.city[i], + country: bucketAssets.country[i], + duration: bucketAssets.duration[i], + id: bucketAssets.id[i], + visibility: bucketAssets.visibility[i], + isFavorite: bucketAssets.isFavorite[i], + isImage: bucketAssets.isImage[i], + isTrashed: bucketAssets.isTrashed[i], + isVideo: !bucketAssets.isImage[i], + livePhotoVideoId: bucketAssets.livePhotoVideoId[i], + localDateTime: bucketAssets.localDateTime[i], + ownerId: bucketAssets.ownerId[i], + people, + projectionType: bucketAssets.projectionType[i], + ratio: bucketAssets.ratio[i], + stack: bucketAssets.stack?.[i] + ? { + id: bucketAssets.stack[i]![0], + primaryAssetId: bucketAssets.id[i], + assetCount: Number.parseInt(bucketAssets.stack[i]![1]), + } + : null, + thumbhash: bucketAssets.thumbhash[i], + }; this.addTimelineAsset(timelineAsset, addContext); } + for (const group of addContext.existingDateGroups) { + group.sortAssets(this.#sortOrder); + } + + if (addContext.newDateGroups.size > 0) { + this.sortDateGroups(); + } + addContext.sort(this, this.#sortOrder); + return addContext.unprocessedAssets; } addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) { - const { id, localDateTime } = timelineAsset; + const { localDateTime } = timelineAsset; const date = DateTime.fromISO(localDateTime).toUTC(); - const month = date.get('month'); const year = date.get('year'); - // If the timeline asset does not belong to the current bucket, mark it as unprocessed if (this.month !== month || this.year !== year) { addContext.unprocessedAssets.push(timelineAsset); return; } const day = date.get('day'); - let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day); + let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day); if (dateGroup) { - // Cache the found date group for future lookups - addContext.lookupCache[day] = dateGroup; + addContext.setDateGroup(dateGroup, year, month, day); } else { - // Create a new date group if none exists for the given day dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); this.dateGroups.push(dateGroup); - addContext.lookupCache[day] = dateGroup; + addContext.setDateGroup(dateGroup, year, month, day); addContext.newDateGroups.add(dateGroup); } - // Check for duplicate assets in the date group - if (dateGroup.intersetingAssets.some((a) => a.id === id)) { - console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`); - return; - } - - // Add the timeline asset to the date group const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset); dateGroup.intersetingAssets.push(intersectingAsset); addContext.changedDateGroups.add(dateGroup); @@ -521,6 +583,7 @@ export class AssetBucket { } } } + get bucketHeight() { return this.#bucketHeight; } @@ -909,7 +972,6 @@ export class AssetStore { async #initialiazeTimeBuckets() { const timebuckets = await getTimeBuckets({ ...this.#options, - size: TimeBucketSize.Month, key: authManager.key, }); @@ -1016,6 +1078,7 @@ export class AssetStore { rowWidth: Math.floor(viewportWidth), }; } + #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { if (invalidateHeight) { bucket.isBucketHeightActual = false; @@ -1117,7 +1180,7 @@ export class AssetStore { { ...this.#options, timeBucket: bucketDate, - size: TimeBucketSize.Month, + key: authManager.key, }, { signal }, @@ -1128,12 +1191,11 @@ export class AssetStore { { albumId: this.#options.timelineAlbumId, timeBucket: bucketDate, - size: TimeBucketSize.Month, key: authManager.key, }, { signal }, ); - for (const { id } of albumAssets) { + for (const id of albumAssets.id) { this.albumAssets.add(id); } } @@ -1169,9 +1231,10 @@ export class AssetStore { if (assets.length === 0) { return; } - const updatedBuckets = new Set<AssetBucket>(); - const updatedDateGroups = new Set<AssetDateGroup>(); + const addContext = new AddContext(); + const updatedBuckets = new Set<AssetBucket>(); + const bucketCount = this.buckets.length; for (const asset of assets) { const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); const year = utc.get('year'); @@ -1182,20 +1245,26 @@ export class AssetStore { bucket = new AssetBucket(this, utc, 1, this.#options.order); this.buckets.push(bucket); } - const addContext = new AddContext(); + bucket.addTimelineAsset(asset, addContext); - addContext.sort(bucket, this.#options.order); updatedBuckets.add(bucket); } - this.buckets.sort((a, b) => { - return a.year === b.year ? b.month - a.month : b.year - a.year; - }); - - for (const dateGroup of updatedDateGroups) { - dateGroup.sortAssets(this.#options.order); + if (this.buckets.length !== bucketCount) { + this.buckets.sort((a, b) => { + return a.year === b.year ? b.month - a.month : b.year - a.year; + }); } - for (const bucket of updatedBuckets) { + + for (const group of addContext.existingDateGroups) { + group.sortAssets(this.#options.order); + } + + for (const bucket of addContext.bucketsWithNewDateGroups) { + bucket.sortDateGroups(); + } + + for (const bucket of addContext.updatedBuckets) { bucket.sortDateGroups(); this.#updateGeometry(bucket, true); } @@ -1421,7 +1490,7 @@ export class AssetStore { isExcluded(asset: TimelineAsset) { return ( - isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) || + isMismatched(this.#options.visibility, asset.visibility) || isMismatched(this.#options.isFavorite, asset.isFavorite) || isMismatched(this.#options.isTrashed, asset.isTrashed) ); diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 57672d0450..08a22de3f9 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -1,7 +1,7 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { StackResponse } from '$lib/utils/asset-utils'; -import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk'; +import { AssetVisibility, deleteAssets as deleteBulk } from '@immich/sdk'; import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; import { handleError } from './handle-error'; @@ -11,7 +11,7 @@ export type OnRestore = (ids: string[]) => void; export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; export type OnAddToAlbum = (ids: string[], albumId: string) => void; -export type OnArchive = (ids: string[], visibility: Visibility) => void; +export type OnArchive = (ids: string[], visibility: AssetVisibility) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (result: StackResponse) => void; export type OnUnstack = (assets: TimelineAsset[]) => void; diff --git a/web/src/lib/utils/thumbnail-util.spec.ts b/web/src/lib/utils/thumbnail-util.spec.ts index ad0c00a50e..f3f9d51fad 100644 --- a/web/src/lib/utils/thumbnail-util.spec.ts +++ b/web/src/lib/utils/thumbnail-util.spec.ts @@ -1,6 +1,6 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { getAltText } from '$lib/utils/thumbnail-util'; -import { Visibility } from '@immich/sdk'; +import { AssetVisibility } from '@immich/sdk'; import { init, register, waitLocale } from 'svelte-i18n'; interface Person { @@ -62,7 +62,7 @@ describe('getAltText', () => { ratio: 1, thumbhash: null, localDateTime: '2024-01-01T12:00:00.000Z', - visibility: Visibility.Timeline, + visibility: AssetVisibility.Timeline, isFavorite: false, isTrashed: false, isVideo, @@ -71,11 +71,9 @@ describe('getAltText', () => { duration: null, projectionType: null, livePhotoVideoId: null, - text: { - city: city ?? null, - country: country ?? null, - people: people?.map((person: Person) => person.name) ?? [], - }, + city: city ?? null, + country: country ?? null, + people: people?.map((person: Person) => person.name) ?? [], }; getAltText.subscribe((fn) => { diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index a6fee0a71d..954cfa3314 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -41,19 +41,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number export const getAltText = derived(t, ($t) => { return (asset: TimelineAsset) => { const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); - const { city, country, people: names } = asset.text; - const hasPlace = city && country; + const hasPlace = asset.city && asset.country; - const peopleCount = names.length; + const peopleCount = asset.people.length; const isVideo = asset.isVideo; const values = { date, - city, - country, - person1: names[0], - person2: names[1], - person3: names[2], + city: asset.city, + country: asset.country, + person1: asset.people[0], + person2: asset.people[1], + person3: asset.people[2], isVideo, additionalCount: peopleCount > 3 ? peopleCount - 2 : 0, }; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 8c5f4d285a..66436940d5 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -2,7 +2,8 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; -import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; +import { AssetTypeEnum, AssetVisibility, type AssetResponseDto } from '@immich/sdk'; + import { memoize } from 'lodash-es'; import { DateTime, type LocaleOptions } from 'luxon'; import { get } from 'svelte/store'; @@ -65,17 +66,12 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): if (isTimelineAsset(unknownAsset)) { return unknownAsset; } - const assetResponse = unknownAsset as AssetResponseDto; + const assetResponse = unknownAsset; const { width, height } = getAssetRatio(assetResponse); const ratio = width / height; const city = assetResponse.exifInfo?.city; const country = assetResponse.exifInfo?.country; const people = assetResponse.people?.map((person) => person.name) || []; - const text = { - city: city || null, - country: country || null, - people, - }; return { id: assetResponse.id, ownerId: assetResponse.ownerId, @@ -83,7 +79,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): thumbhash: assetResponse.thumbhash, localDateTime: assetResponse.localDateTime, isFavorite: assetResponse.isFavorite, - visibility: assetResponse.visibility, + visibility: assetResponse.isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline, isTrashed: assetResponse.isTrashed, isVideo: assetResponse.type == AssetTypeEnum.Video, isImage: assetResponse.type == AssetTypeEnum.Image, @@ -91,8 +87,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): duration: assetResponse.duration || null, projectionType: assetResponse.exifInfo?.projectionType || null, livePhotoVideoId: assetResponse.livePhotoVideoId || null, - text, + city: city || null, + country: country || null, + people, }; }; -export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset => - (asset as TimelineAsset).ratio !== undefined; +export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => + (unknownAsset as TimelineAsset).ratio !== undefined; diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index bdffecc8bc..e36bec6c4e 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -1,6 +1,12 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { faker } from '@faker-js/faker'; -import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk'; +import { + AssetTypeEnum, + AssetVisibility, + Visibility, + type AssetResponseDto, + type TimeBucketAssetResponseDto, +} from '@immich/sdk'; import { Sync } from 'factory.ts'; export const assetFactory = Sync.makeFactory<AssetResponseDto>({ @@ -35,7 +41,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({ thumbhash: Sync.each(() => faker.string.alphanumeric(28)), localDateTime: Sync.each(() => faker.date.past().toISOString()), isFavorite: Sync.each(() => faker.datatype.boolean()), - visibility: Visibility.Timeline, + visibility: AssetVisibility.Timeline, isTrashed: false, isImage: true, isVideo: false, @@ -43,9 +49,46 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({ stack: null, projectionType: null, livePhotoVideoId: Sync.each(() => faker.string.uuid()), - text: Sync.each(() => ({ - city: faker.location.city(), - country: faker.location.country(), - people: [faker.person.fullName()], - })), + city: faker.location.city(), + country: faker.location.country(), + people: [faker.person.fullName()], }); + +export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { + const bucketAssets: TimeBucketAssetResponseDto = { + city: [], + country: [], + duration: [], + id: [], + visibility: [], + isFavorite: [], + isImage: [], + isTrashed: [], + livePhotoVideoId: [], + localDateTime: [], + ownerId: [], + projectionType: [], + ratio: [], + stack: [], + thumbhash: [], + }; + for (const asset of timelineAsset) { + bucketAssets.city.push(asset.city); + bucketAssets.country.push(asset.country); + bucketAssets.duration.push(asset.duration!); + bucketAssets.id.push(asset.id); + bucketAssets.visibility.push(asset.visibility); + bucketAssets.isFavorite.push(asset.isFavorite); + bucketAssets.isImage.push(asset.isImage); + bucketAssets.isTrashed.push(asset.isTrashed); + bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!); + bucketAssets.localDateTime.push(asset.localDateTime); + bucketAssets.ownerId.push(asset.ownerId); + bucketAssets.projectionType.push(asset.projectionType!); + bucketAssets.ratio.push(asset.ratio); + bucketAssets.stack?.push(asset.stack ? [asset.stack.id, asset.stack.assetCount.toString()] : null); + bucketAssets.thumbhash.push(asset.thumbhash!); + } + + return bucketAssets; +};