push aggregation to query

This commit is contained in:
mertalev 2025-05-04 19:24:08 -04:00
parent 07c03b8a79
commit 97cc9e223e
No known key found for this signature in database
GPG Key ID: DF6ABC77AAD98C95
8 changed files with 219 additions and 263 deletions

View File

@ -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 { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
@ -19,7 +20,13 @@ export class TimelineController {
@Get('bucket') @Get('bucket')
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { async getTimeBucket(
return this.service.getTimeBucket(auth, dto); @Auth() auth: AuthDto,
@Query() dto: TimeBucketAssetDto,
@Res({ passthrough: true }) res: Response,
): Promise<TimeBucketAssetResponseDto> {
res.contentType('application/json');
const jsonBucket = await this.service.getTimeBucket(auth, dto);
return jsonBucket as unknown as TimeBucketAssetResponseDto;
} }
} }

View File

@ -165,6 +165,12 @@ export type Stack = {
assetCount?: number; assetCount?: number;
}; };
export type TimelineStack = {
id: string;
primaryAssetId: string;
assetCount: number;
};
export type AuthSharedLink = { export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;

View File

@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsInt, IsString, Min } from 'class-validator'; import { IsEnum, IsInt, IsString, Min } from 'class-validator';
import { AssetOrder } from 'src/enum'; 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'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
export class TimeBucketDto { export class TimeBucketDto {
@ -49,73 +49,56 @@ export class TimeBucketAssetDto extends TimeBucketDto {
page?: number; page?: number;
@IsInt() @IsInt()
@Min(1)
@Optional() @Optional()
pageSize?: number; pageSize?: number;
} }
export class TimelineStackResponseDto implements TimelineStack { export class TimelineStackResponseDto implements TimelineStack {
@ApiProperty()
id!: string; id!: string;
@ApiProperty()
primaryAssetId!: string; primaryAssetId!: string;
@ApiProperty()
assetCount!: number; assetCount!: number;
} }
export class TimelineAssetDescriptionDto implements AssetDescription {
@ApiProperty()
city!: string | null;
@ApiProperty()
country!: string | null;
}
export class TimeBucketAssetResponseDto implements TimeBucketAssets { 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() @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
isVideo: number[] = []; thumbhash!: (string | null)[];
@ApiProperty({ type: [String] }) localDateTime!: string[];
thumbhash: (string | null)[] = [];
@ApiProperty({ type: [String] }) @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
localDateTime: string[] = []; duration!: (string | null)[];
@ApiProperty({ type: [String] }) stackCount?: number[];
duration: (string | null)[] = [];
@ApiProperty({ type: [TimelineStackResponseDto] }) @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
stack: (TimelineStackResponseDto | null)[] = []; stackId?: (string | null)[];
@ApiProperty({ type: [String] }) @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
projectionType: (string | null)[] = []; projectionType!: (string | null)[];
@ApiProperty({ type: [String] }) @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
livePhotoVideoId: (string | null)[] = []; livePhotoVideoId!: (string | null)[];
@ApiProperty() @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
description: TimelineAssetDescriptionDto[] = []; city!: (string | null)[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
country!: (string | null)[];
} }
export class TimeBucketsResponseDto { export class TimeBucketsResponseDto {
@ -125,11 +108,3 @@ export class TimeBucketsResponseDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
count!: number; count!: number;
} }
export class TimeBucketResponseDto {
@ApiProperty({ type: TimeBucketAssetResponseDto })
bucketAssets!: TimeBucketAssetResponseDto;
@ApiProperty()
hasNextPage!: boolean;
}

View File

@ -11,7 +11,6 @@ import {
anyUuid, anyUuid,
asUuid, asUuid,
hasPeople, hasPeople,
hasPeopleNoJoin,
removeUndefinedKeys, removeUndefinedKeys,
truncatedDate, truncatedDate,
unnest, unnest,
@ -23,11 +22,9 @@ import {
withOwner, withOwner,
withSmartSearch, withSmartSearch,
withTagId, withTagId,
withTagIdNoWhere,
withTags, withTags,
} from 'src/utils/database'; } from 'src/utils/database';
import { globToSqlPattern } from 'src/utils/misc'; import { globToSqlPattern } from 'src/utils/misc';
import { PaginationOptions } from 'src/utils/pagination';
export type AssetStats = Record<AssetType, number>; export type AssetStats = Record<AssetType, number>;
@ -584,84 +581,126 @@ export class AssetRepository {
} }
@GenerateSql({ @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) { getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
const paginate = pagination.skip! >= 1 && pagination.take >= 1;
const query = this.db const query = this.db
.with('cte', (qb) =>
qb
.selectFrom('assets') .selectFrom('assets')
.select([ .innerJoin('exif', 'assets.id', 'exif.assetId')
'assets.id as id', .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.ownerId',
'assets.status', 'assets.status',
'deletedAt', eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
'type', 'exif.city',
'duration', 'exif.country',
'isFavorite',
'isArchived',
'thumbhash',
'localDateTime',
'livePhotoVideoId',
])
.leftJoin('exif', 'assets.id', 'exif.assetId')
.select([
'exif.exifImageHeight as height',
'exifImageWidth as width',
'exif.orientation',
'exif.projectionType', 'exif.projectionType',
'exif.city as city', eb.fn
'exif.country as country', .coalesce(
]) eb
.select(sql<string>`to_json("localDateTime" at time zone 'UTC')#>>'{}'`.as('localDateTime')) .case()
.$if(!!options.albumId, (qb) => .when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`)
qb .then(eb.lit(1.0))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .when('exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
.where('albums_assets_assets.albumsId', '=', options.albumId!), .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),
) )
.$if(!!options.personId, (qb) => .as('ratio'),
qb.innerJoin( ])
() => hasPeopleNoJoin([options.personId!]), .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
(join) => join.onRef('has_people.assetId', '=', 'assets.id'), .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.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) => .$if(!!options.withStacked, (qb) =>
qb qb
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
.where((eb) => .where((eb) =>
eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]), eb.not(
eb.exists(
eb
.selectFrom('asset_stack')
.whereRef('asset_stack.id', '=', 'assets.stackId')
.whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'),
),
),
) )
.leftJoinLateral( .leftJoinLateral(
(eb) => (eb) =>
eb eb
.selectFrom('assets as stacked') .selectFrom('assets as stacked')
.selectAll('asset_stack') .select((eb) => eb.fn.coalesce(eb.fn.count(eb.table('stacked')), eb.lit(0)).as('stackCount'))
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) .whereRef('stacked.stackId', '=', 'assets.stackId')
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.where('stacked.deletedAt', 'is', null) .where('stacked.deletedAt', 'is', null)
.where('stacked.isArchived', '=', false) .where('stacked.isArchived', '=', false)
.groupBy('asset_stack.id')
.as('stacked_assets'), .as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null), (join) => join.onTrue(),
) )
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')), .select(['assets.stackId', 'stackCount']),
) )
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) => .$if(options.isDuplicate !== undefined, (qb) =>
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
) )
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.$if(!!options.tagId, (qb) => qb.where((eb) => withTagIdNoWhere(options.tagId!, eb.ref('assets.id')))) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .orderBy('assets.localDateTime', options.order ?? 'desc'),
.where('assets.isVisible', '=', true) )
.where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, '')) .with('agg', (qb) =>
.orderBy('assets.localDateTime', options.order ?? 'desc') qb
.$if(paginate, (qb) => qb.offset(pagination.skip!)) .selectFrom('cte')
.$if(paginate, (qb) => qb.limit(pagination.take + 1)); .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'),
]),
),
)
.selectFrom('agg')
.select(sql<string>`to_json(agg)::text`.as('assets'));
return await query.execute(); return query.executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })

View File

@ -1,19 +1,11 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { round } from 'lodash';
import { Stack } from 'src/database'; import { Stack } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto';
TimeBucketAssetDto, import { Permission } from 'src/enum';
TimeBucketDto,
TimeBucketResponseDto,
TimeBucketsResponseDto,
} from 'src/dtos/time-bucket.dto';
import { AssetType, Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { TimeBucketOptions } from 'src/repositories/asset.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { TimeBucketAssets } from 'src/services/timeline.service.types'; import { getMyPartnerIds } from 'src/utils/asset.util';
import { getMyPartnerIds, isFlipped } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@Injectable() @Injectable()
export class TimelineService extends BaseService { export class TimelineService extends BaseService {
@ -23,76 +15,14 @@ export class TimelineService extends BaseService {
return await this.assetRepository.getTimeBuckets(timeBucketOptions); return await this.assetRepository.getTimeBuckets(timeBucketOptions);
} }
async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<TimeBucketResponseDto> { // pre-jsonified response
async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> {
await this.timeBucketChecks(auth, dto); await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto }); const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto });
const page = dto.page || 1; // TODO: use id cursor for pagination
const size = dto.pageSize || -1; const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
if (dto.pageSize === 0) { return bucket.assets;
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,
};
} }
mapStack(entity?: Stack | null) { mapStack(entity?: Stack | null) {

View File

@ -16,13 +16,14 @@ export type TimeBucketAssets = {
isFavorite: number[]; isFavorite: number[];
isArchived: number[]; isArchived: number[];
isTrashed: number[]; isTrashed: number[];
isVideo: number[];
isImage: number[]; isImage: number[];
thumbhash: (string | null)[]; thumbhash: (string | null)[];
localDateTime: string[]; localDateTime: string[];
stack: (TimelineStack | null)[]; stackCount?: number[];
stackId?: (string | null)[];
duration: (string | null)[]; duration: (string | null)[];
projectionType: (string | null)[]; projectionType: (string | null)[];
livePhotoVideoId: (string | null)[]; livePhotoVideoId: (string | null)[];
description: AssetDescription[]; city: (string | null)[];
country: (string | null)[];
}; };

View File

@ -1,7 +1,6 @@
import { import {
DeduplicateJoinsPlugin, DeduplicateJoinsPlugin,
Expression, Expression,
expressionBuilder,
ExpressionBuilder, ExpressionBuilder,
ExpressionWrapper, ExpressionWrapper,
Kysely, Kysely,
@ -211,19 +210,18 @@ export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDele
} }
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) { export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) {
return qb.innerJoin(hasPeopleNoJoin(personIds), (join) => join.onRef('has_people.assetId', '=', 'assets.id')); return qb.innerJoin(
} (eb) =>
eb
export function hasPeopleNoJoin(personIds: string[]) {
const eb = expressionBuilder<DB, never>();
return eb
.selectFrom('asset_faces') .selectFrom('asset_faces')
.select('assetId') .select('assetId')
.where('personId', '=', anyUuid(personIds!)) .where('personId', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.groupBy('assetId') .groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people'); .as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'assets.id'),
);
} }
export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: string[]) { export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: string[]) {
@ -264,21 +262,18 @@ export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
} }
export function truncatedDate<O>(size: TimeBucketSize) { export function truncatedDate<O>(size: TimeBucketSize) {
return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; return sql<O>`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
} }
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) { export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
return qb.where((eb) => withTagIdNoWhere(tagId, eb.ref('assets.id'))); return qb.where((eb) =>
} eb.exists(
export function withTagIdNoWhere(tagId: string, assetId: Expression<string>) {
const eb = expressionBuilder<DB, never>();
return eb.exists(
eb eb
.selectFrom('tags_closure') .selectFrom('tags_closure')
.innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant') .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
.whereRef('tag_asset.assetsId', '=', assetId) .whereRef('tag_asset.assetsId', '=', 'assets.id')
.where('tags_closure.id_ancestor', '=', tagId), .where('tags_closure.id_ancestor', '=', tagId),
),
); );
} }

View File

@ -15,7 +15,7 @@ import {
getTimeBucket, getTimeBucket,
getTimeBuckets, getTimeBuckets,
type AssetStackResponseDto, type AssetStackResponseDto,
type TimeBucketResponseDto, type TimeBucketAssetResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { clamp, debounce, isEqual, throttle } from 'lodash-es';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -81,11 +81,9 @@ export type TimelineAsset = {
duration: string | null; duration: string | null;
projectionType: string | null; projectionType: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
description: {
city: string | null; city: string | null;
country: string | null; country: string | null;
people: string[]; people: string[];
};
}; };
class IntersectingAsset { class IntersectingAsset {
// --- public --- // --- public ---
@ -420,28 +418,33 @@ export class AssetBucket {
} }
// note - if the assets are not part of this bucket, they will not be added // 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(); 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 = { const timelineAsset: TimelineAsset = {
description: { city: bucketAssets.city[i],
...bucketResponse.bucketAssets.description[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: [], people: [],
}, projectionType: bucketAssets.projectionType[i],
duration: bucketResponse.bucketAssets.duration[i], ratio: bucketAssets.ratio[i],
id: bucketResponse.bucketAssets.id[i], stack: bucketAssets.stackId?.[i]
isArchived: !!bucketResponse.bucketAssets.isArchived[i], ? {
isFavorite: !!bucketResponse.bucketAssets.isFavorite[i], id: bucketAssets.stackId[i]!,
isImage: !!bucketResponse.bucketAssets.isImage[i], primaryAssetId: bucketAssets.id[i],
isTrashed: !!bucketResponse.bucketAssets.isTrashed[i], assetCount: bucketAssets.stackCount![i],
isVideo: !!bucketResponse.bucketAssets.isVideo[i], }
livePhotoVideoId: bucketResponse.bucketAssets.livePhotoVideoId[i], : null,
localDateTime: bucketResponse.bucketAssets.localDateTime[i], thumbhash: bucketAssets.thumbhash[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],
}; };
this.addTimelineAsset(timelineAsset, addContext); this.addTimelineAsset(timelineAsset, addContext);
} }
@ -1144,7 +1147,7 @@ export class AssetStore {
}, },
{ signal }, { signal },
); );
for (const id of albumAssets.bucketAssets.id) { for (const id of albumAssets.id) {
this.albumAssets.add(id); this.albumAssets.add(id);
} }
} }