diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 505d2d3b30..9596457126 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto } 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'; @@ -19,7 +20,13 @@ export class TimelineController { @Get('bucket') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { - return this.service.getTimeBucket(auth, dto); + async getTimeBucket( + @Auth() auth: AuthDto, + @Query() dto: TimeBucketAssetDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + res.contentType('application/json'); + const jsonBucket = await this.service.getTimeBucket(auth, dto); + return jsonBucket as unknown as TimeBucketAssetResponseDto; } } diff --git a/server/src/database.ts b/server/src/database.ts index a93873ef42..4c9e57df58 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -165,6 +165,12 @@ export type Stack = { assetCount?: number; }; +export type TimelineStack = { + id: string; + primaryAssetId: string; + assetCount: number; +}; + export type AuthSharedLink = { id: string; expiresAt: Date | null; diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 4fddd01b1a..5703ea7707 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsInt, IsString, Min } from 'class-validator'; import { AssetOrder } from 'src/enum'; -import { AssetDescription, TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; +import { TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @@ -49,73 +49,56 @@ export class TimeBucketAssetDto extends TimeBucketDto { page?: number; @IsInt() + @Min(1) @Optional() pageSize?: number; } export class TimelineStackResponseDto implements TimelineStack { - @ApiProperty() id!: string; - - @ApiProperty() primaryAssetId!: string; - - @ApiProperty() assetCount!: number; } -export class TimelineAssetDescriptionDto implements AssetDescription { - @ApiProperty() - city!: string | null; - @ApiProperty() - country!: string | null; -} - export class TimeBucketAssetResponseDto implements TimeBucketAssets { - @ApiProperty({ type: [String] }) - id: string[] = []; + id!: string[]; - @ApiProperty({ type: [String] }) - ownerId: string[] = []; + ownerId!: string[]; - @ApiProperty() - ratio: number[] = []; + ratio!: number[]; - @ApiProperty() - isFavorite: number[] = []; + isFavorite!: number[]; - @ApiProperty() - isArchived: number[] = []; + isArchived!: number[]; - @ApiProperty() - isTrashed: number[] = []; + isTrashed!: number[]; - @ApiProperty() - isImage: number[] = []; + isImage!: number[]; - @ApiProperty() - isVideo: number[] = []; + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + thumbhash!: (string | null)[]; - @ApiProperty({ type: [String] }) - thumbhash: (string | null)[] = []; + localDateTime!: string[]; - @ApiProperty({ type: [String] }) - localDateTime: string[] = []; + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + duration!: (string | null)[]; - @ApiProperty({ type: [String] }) - duration: (string | null)[] = []; + stackCount?: number[]; - @ApiProperty({ type: [TimelineStackResponseDto] }) - stack: (TimelineStackResponseDto | null)[] = []; + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + stackId?: (string | null)[]; - @ApiProperty({ type: [String] }) - projectionType: (string | null)[] = []; + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + projectionType!: (string | null)[]; - @ApiProperty({ type: [String] }) - livePhotoVideoId: (string | null)[] = []; + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + livePhotoVideoId!: (string | null)[]; - @ApiProperty() - description: TimelineAssetDescriptionDto[] = []; + @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 { @@ -125,11 +108,3 @@ export class TimeBucketsResponseDto { @ApiProperty({ type: 'integer' }) count!: number; } - -export class TimeBucketResponseDto { - @ApiProperty({ type: TimeBucketAssetResponseDto }) - bucketAssets!: TimeBucketAssetResponseDto; - - @ApiProperty() - hasNextPage!: boolean; -} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index d4b93e5cc7..f3e40a5598 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -11,7 +11,6 @@ import { anyUuid, asUuid, hasPeople, - hasPeopleNoJoin, removeUndefinedKeys, truncatedDate, unnest, @@ -23,11 +22,9 @@ import { withOwner, withSmartSearch, withTagId, - withTagIdNoWhere, withTags, } from 'src/utils/database'; import { globToSqlPattern } from 'src/utils/misc'; -import { PaginationOptions } from 'src/utils/pagination'; export type AssetStats = Record; @@ -584,84 +581,126 @@ export class AssetRepository { } @GenerateSql({ - params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }, { skip: -1, take: 1000 }], + params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }, { skip: 0, take: 1000 }], }) - async getTimeBucket(timeBucket: string, options: TimeBucketOptions, pagination: PaginationOptions) { - const paginate = pagination.skip! >= 1 && pagination.take >= 1; + getTimeBucket(timeBucket: string, options: TimeBucketOptions) { const query = this.db - .selectFrom('assets') - .select([ - 'assets.id as id', - 'assets.ownerId', - 'assets.status', - 'deletedAt', - 'type', - 'duration', - 'isFavorite', - 'isArchived', - 'thumbhash', - 'localDateTime', - 'livePhotoVideoId', - ]) - .leftJoin('exif', 'assets.id', 'exif.assetId') - .select([ - 'exif.exifImageHeight as height', - 'exifImageWidth as width', - 'exif.orientation', - 'exif.projectionType', - 'exif.city as city', - 'exif.country as country', - ]) - .select(sql`to_json("localDateTime" at time zone 'UTC')#>>'{}'`.as('localDateTime')) - .$if(!!options.albumId, (qb) => + .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', + sql`assets."isArchived"::int`.as('isArchived'), + sql`assets."isFavorite"::int`.as('isFavorite'), + sql`(assets.type = 'IMAGE')::int`.as('isImage'), + sql`(assets."deletedAt" is null)::int`.as('isTrashed'), + sql`(assets.type = 'VIDEO')::int`.as('isVideo'), + '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.0)) + .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.0), + ) + .as('ratio'), + ]) + .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .where('assets.isVisible', '=', true) + .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.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) + .$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((eb) => eb.fn.coalesce(eb.fn.count(eb.table('stacked')), eb.lit(0)).as('stackCount')) + .whereRef('stacked.stackId', '=', 'assets.stackId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.isArchived', '=', false) + .as('stacked_assets'), + (join) => join.onTrue(), + ) + .select(['assets.stackId', 'stackCount']), + ) + .$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) => - qb.innerJoin( - () => hasPeopleNoJoin([options.personId!]), - (join) => join.onRef('has_people.assetId', '=', 'assets.id'), - ), - ) - .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) - .$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.isArchived', '=', false) - .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', ['isArchived']), sql.lit('{}')).as('isArchived'), + 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 0s or 1s 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('array_agg', ['stackCount']).as('stackCount'), + eb.fn('array_agg', ['stackId']).as('stackId'), + ]), + ), ) - .$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) => qb.where((eb) => withTagIdNoWhere(options.tagId!, eb.ref('assets.id')))) - .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) - .where('assets.isVisible', '=', true) - .where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, '')) - .orderBy('assets.localDateTime', options.order ?? 'desc') - .$if(paginate, (qb) => qb.offset(pagination.skip!)) - .$if(paginate, (qb) => qb.limit(pagination.take + 1)); + .selectFrom('agg') + .select(sql`to_json(agg)::text`.as('assets')); - return await query.execute(); + return query.executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 9332f83745..578509efdb 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,19 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { round } from 'lodash'; import { Stack } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { - TimeBucketAssetDto, - TimeBucketDto, - TimeBucketResponseDto, - TimeBucketsResponseDto, -} from 'src/dtos/time-bucket.dto'; -import { AssetType, Permission } from 'src/enum'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto'; +import { Permission } from 'src/enum'; import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; -import { TimeBucketAssets } from 'src/services/timeline.service.types'; -import { getMyPartnerIds, isFlipped } from 'src/utils/asset.util'; -import { hexOrBufferToBase64 } from 'src/utils/bytes'; +import { getMyPartnerIds } from 'src/utils/asset.util'; @Injectable() export class TimelineService extends BaseService { @@ -23,76 +15,14 @@ export class TimelineService extends BaseService { 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 page = dto.page || 1; - const size = dto.pageSize || -1; - if (dto.pageSize === 0) { - throw new BadRequestException('pageSize must not be 0'); - } - const paginate = page >= 1 && size >= 1; - const items = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions, { - skip: page, - take: size, - }); - - const hasNextPage = paginate && items.length > size; - if (paginate) { - items.splice(size); - } - - const bucketAssets: TimeBucketAssets = { - id: [], - ownerId: [], - ratio: [], - isFavorite: [], - isArchived: [], - isTrashed: [], - isVideo: [], - isImage: [], - thumbhash: [], - localDateTime: [], - stack: [], - duration: [], - projectionType: [], - livePhotoVideoId: [], - description: [], - }; - for (const item of items) { - let width = item.width!; - let height = item.height!; - if (isFlipped(item.orientation)) { - const w = item.width!; - const h = item.height!; - height = w; - width = h; - } - bucketAssets.id.push(item.id); - bucketAssets.ownerId.push(item.ownerId); - bucketAssets.ratio.push(round(width / height, 2)); - bucketAssets.isArchived.push(item.isArchived ? 1 : 0); - bucketAssets.isFavorite.push(item.isFavorite ? 1 : 0); - bucketAssets.isTrashed.push(item.deletedAt === null ? 0 : 1); - bucketAssets.thumbhash.push(hexOrBufferToBase64(item.thumbhash)); - bucketAssets.localDateTime.push(item.localDateTime); - bucketAssets.stack.push(this.mapStack(item.stack)); - bucketAssets.duration.push(item.duration); - bucketAssets.projectionType.push(item.projectionType); - bucketAssets.livePhotoVideoId.push(item.livePhotoVideoId); - bucketAssets.isImage.push(item.type === AssetType.IMAGE ? 1 : 0); - bucketAssets.isVideo.push(item.type === AssetType.VIDEO ? 1 : 0); - bucketAssets.description.push({ - city: item.city, - country: item.country, - }); - } - - return { - bucketAssets, - hasNextPage, - }; + // TODO: use id cursor for pagination + const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); + return bucket.assets; } mapStack(entity?: Stack | null) { diff --git a/server/src/services/timeline.service.types.ts b/server/src/services/timeline.service.types.ts index 09a5c0e667..4bf1f77b3b 100644 --- a/server/src/services/timeline.service.types.ts +++ b/server/src/services/timeline.service.types.ts @@ -16,13 +16,14 @@ export type TimeBucketAssets = { isFavorite: number[]; isArchived: number[]; isTrashed: number[]; - isVideo: number[]; isImage: number[]; thumbhash: (string | null)[]; localDateTime: string[]; - stack: (TimelineStack | null)[]; + stackCount?: number[]; + stackId?: (string | null)[]; duration: (string | null)[]; projectionType: (string | null)[]; livePhotoVideoId: (string | null)[]; - description: AssetDescription[]; + city: (string | null)[]; + country: (string | null)[]; }; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 3d30f8a47f..3082a7d8f2 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,7 +1,6 @@ import { DeduplicateJoinsPlugin, Expression, - expressionBuilder, ExpressionBuilder, ExpressionWrapper, Kysely, @@ -211,19 +210,18 @@ export function withFacesAndPeople(eb: ExpressionBuilder, withDele } export function hasPeople(qb: SelectQueryBuilder, personIds: string[]) { - return qb.innerJoin(hasPeopleNoJoin(personIds), (join) => join.onRef('has_people.assetId', '=', 'assets.id')); -} - -export function hasPeopleNoJoin(personIds: string[]) { - const eb = expressionBuilder(); - return eb - .selectFrom('asset_faces') - .select('assetId') - .where('personId', '=', anyUuid(personIds!)) - .where('deletedAt', 'is', null) - .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) - .as('has_people'); + return qb.innerJoin( + (eb) => + eb + .selectFrom('asset_faces') + .select('assetId') + .where('personId', '=', anyUuid(personIds!)) + .where('deletedAt', 'is', null) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .as('has_people'), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), + ); } export function hasTags(qb: SelectQueryBuilder, tagIds: string[]) { @@ -264,21 +262,18 @@ 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) { - return qb.where((eb) => withTagIdNoWhere(tagId, eb.ref('assets.id'))); -} - -export function withTagIdNoWhere(tagId: string, assetId: Expression) { - const eb = expressionBuilder(); - return eb.exists( - eb - .selectFrom('tags_closure') - .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant') - .whereRef('tag_asset.assetsId', '=', assetId) - .where('tags_closure.id_ancestor', '=', tagId), + return qb.where((eb) => + eb.exists( + eb + .selectFrom('tags_closure') + .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant') + .whereRef('tag_asset.assetsId', '=', 'assets.id') + .where('tags_closure.id_ancestor', '=', tagId), + ), ); } diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index 80ee3dbddb..2990724efa 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -15,7 +15,7 @@ import { getTimeBucket, getTimeBuckets, type AssetStackResponseDto, - type TimeBucketResponseDto, + type TimeBucketAssetResponseDto, } from '@immich/sdk'; import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { DateTime } from 'luxon'; @@ -81,11 +81,9 @@ export type TimelineAsset = { duration: string | null; projectionType: string | null; livePhotoVideoId: string | null; - description: { - city: string | null; - country: string | null; - people: string[]; - }; + city: string | null; + country: string | null; + people: string[]; }; class IntersectingAsset { // --- public --- @@ -420,28 +418,33 @@ export class AssetBucket { } // note - if the assets are not part of this bucket, they will not be added - addAssets(bucketResponse: TimeBucketResponseDto) { + addAssets(bucketAssets: TimeBucketAssetResponseDto) { const addContext = new AddContext(); - for (let i = 0; i < bucketResponse.bucketAssets.id.length; i++) { + for (let i = 0; i < bucketAssets.id.length; i++) { const timelineAsset: TimelineAsset = { - description: { - ...bucketResponse.bucketAssets.description[i], - people: [], - }, - duration: bucketResponse.bucketAssets.duration[i], - id: bucketResponse.bucketAssets.id[i], - isArchived: !!bucketResponse.bucketAssets.isArchived[i], - isFavorite: !!bucketResponse.bucketAssets.isFavorite[i], - isImage: !!bucketResponse.bucketAssets.isImage[i], - isTrashed: !!bucketResponse.bucketAssets.isTrashed[i], - isVideo: !!bucketResponse.bucketAssets.isVideo[i], - livePhotoVideoId: bucketResponse.bucketAssets.livePhotoVideoId[i], - localDateTime: bucketResponse.bucketAssets.localDateTime[i], - ownerId: bucketResponse.bucketAssets.ownerId[i], - projectionType: bucketResponse.bucketAssets.projectionType[i], - ratio: bucketResponse.bucketAssets.ratio[i], - stack: bucketResponse.bucketAssets.stack[i], - thumbhash: bucketResponse.bucketAssets.thumbhash[i], + city: bucketAssets.city[i], + country: bucketAssets.country[i], + duration: bucketAssets.duration[i], + id: bucketAssets.id[i], + isArchived: Boolean(bucketAssets.isArchived[i]), + isFavorite: Boolean(bucketAssets.isFavorite[i]), + isImage: Boolean(bucketAssets.isImage[i]), + isTrashed: Boolean(bucketAssets.isTrashed[i]), + isVideo: !bucketAssets.isImage[i], + livePhotoVideoId: bucketAssets.livePhotoVideoId[i], + localDateTime: bucketAssets.localDateTime[i], + ownerId: bucketAssets.ownerId[i], + people: [], + projectionType: bucketAssets.projectionType[i], + ratio: bucketAssets.ratio[i], + stack: bucketAssets.stackId?.[i] + ? { + id: bucketAssets.stackId[i]!, + primaryAssetId: bucketAssets.id[i], + assetCount: bucketAssets.stackCount![i], + } + : null, + thumbhash: bucketAssets.thumbhash[i], }; this.addTimelineAsset(timelineAsset, addContext); } @@ -1144,7 +1147,7 @@ export class AssetStore { }, { signal }, ); - for (const id of albumAssets.bucketAssets.id) { + for (const id of albumAssets.id) { this.albumAssets.add(id); } }