diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index 93ba8b652..79bf748e9 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 620fc9766..2c5dea7f1 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8710298d7..541614ca5 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 1d25a379e..399e7bde8 100644 Binary files a/mobile/openapi/lib/api/timeline_api.dart and b/mobile/openapi/lib/api/timeline_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a3b1c41ca..540dc1130 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4928adf76..1618f4a67 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ 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 000000000..3f1406c01 Binary files /dev/null and b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart differ 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 e843b43f4..000000000 Binary files a/mobile/openapi/lib/model/time_bucket_size.dart and /dev/null differ 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 56044b27a..8c9f8dab6 100644 Binary files a/mobile/openapi/lib/model/time_bucket_response_dto.dart and b/mobile/openapi/lib/model/time_buckets_response_dto.dart differ diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index e2badc6df..d6f133348 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 000000000..a59e30091 --- /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 c293b2aa6..5358cdfec 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 b791358a9..a114830e0 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 92de84d34..b4ee04262 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 2a44a34b5..4c1f2571e 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 51d46871a..f68ce9307 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 f4f13c4d2..8f25cbbd4 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 e118bf39a..f2f323f71 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 6ad488c48..bd3c09098 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 1447594d4..1669b1eac 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 c0cd4786a..f3ebcc2cd 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 e837c81b9..5e476f4de 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 bacdf06d6..e0e7af49a 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 4248b23d3..ce1520c47 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 a64194361..454be0084 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 000000000..ca6fc5e1d --- /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 80dfb3506..be5e8f782 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 @@