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 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 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?> 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 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') as List) - .cast() - .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 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 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?> 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?> 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') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .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 city; + + List country; + + List duration; + + List id; + + List isFavorite; + + List isImage; + + List isTrashed; + + List livePhotoVideoId; + + List localDateTime; + + List ownerId; + + List projectionType; + + List ratio; + + /// (stack ID, stack asset count) tuple + List?> stack; + + List thumbhash; + + List 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 toJson() { + final json = {}; + 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(); + + return TimeBucketAssetResponseDto( + city: json[r'city'] is Iterable + ? (json[r'city'] as Iterable).cast().toList(growable: false) + : const [], + country: json[r'country'] is Iterable + ? (json[r'country'] as Iterable).cast().toList(growable: false) + : const [], + duration: json[r'duration'] is Iterable + ? (json[r'duration'] as Iterable).cast().toList(growable: false) + : const [], + id: json[r'id'] is Iterable + ? (json[r'id'] as Iterable).cast().toList(growable: false) + : const [], + isFavorite: json[r'isFavorite'] is Iterable + ? (json[r'isFavorite'] as Iterable).cast().toList(growable: false) + : const [], + isImage: json[r'isImage'] is Iterable + ? (json[r'isImage'] as Iterable).cast().toList(growable: false) + : const [], + isTrashed: json[r'isTrashed'] is Iterable + ? (json[r'isTrashed'] as Iterable).cast().toList(growable: false) + : const [], + livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable + ? (json[r'livePhotoVideoId'] as Iterable).cast().toList(growable: false) + : const [], + localDateTime: json[r'localDateTime'] is Iterable + ? (json[r'localDateTime'] as Iterable).cast().toList(growable: false) + : const [], + ownerId: json[r'ownerId'] is Iterable + ? (json[r'ownerId'] as Iterable).cast().toList(growable: false) + : const [], + projectionType: json[r'projectionType'] is Iterable + ? (json[r'projectionType'] as Iterable).cast().toList(growable: false) + : const [], + ratio: json[r'ratio'] is Iterable + ? (json[r'ratio'] as Iterable).cast().toList(growable: false) + : const [], + stack: json[r'stack'] is List + ? (json[r'stack'] as List).map((e) => + e == null ? null : (e as List).cast() + ).toList() + : const [], + thumbhash: json[r'thumbhash'] is Iterable + ? (json[r'thumbhash'] as Iterable).cast().toList(growable: false) + : const [], + visibility: AssetVisibility.listFromJson(json[r'visibility']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TimeBucketAssetResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TimeBucketAssetResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + '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 = [ - DAY, - MONTH, - ]; - - static TimeBucketSize? fromJson(dynamic value) => TimeBucketSizeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 toJson() { final json = {}; @@ -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(); - return TimeBucketResponseDto( + return TimeBucketsResponseDto( count: mapValueOfType(json, r'count')!, timeBucket: mapValueOfType(json, r'timeBucket')!, ); @@ -58,11 +58,11 @@ class TimeBucketResponseDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; 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 mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of TimeBucketsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = 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 {{/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 { + 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 { - return this.service.getTimeBucket(auth, dto) as Promise; + @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(options.size).as('timeBucket')) + .select(truncatedDate(TimeBucketSize.MONTH).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility) @@ -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`('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()).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`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 { + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { 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 { + // pre-jsonified response + async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise { 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 { 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) { } export function truncatedDate(size: TimeBucketSize) { - return sql`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; + return sql`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; } export function withTagId(qb: SelectQueryBuilder, tagId: string) { @@ -285,6 +285,7 @@ export function withTagId(qb: SelectQueryBuilder, 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 @@