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 { 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<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;
};
export type TimelineStack = {
id: string;
primaryAssetId: string;
assetCount: number;
};
export type AuthSharedLink = {
id: string;
expiresAt: Date | null;

View File

@ -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;
}

View File

@ -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<AssetType, number>;
@ -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
.with('cte', (qb) =>
qb
.selectFrom('assets')
.select([
'assets.id as id',
.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',
'deletedAt',
'type',
'duration',
'isFavorite',
'isArchived',
'thumbhash',
'localDateTime',
'livePhotoVideoId',
])
.leftJoin('exif', 'assets.id', 'exif.assetId')
.select([
'exif.exifImageHeight as height',
'exifImageWidth as width',
'exif.orientation',
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
'exif.city',
'exif.country',
'exif.projectionType',
'exif.city as city',
'exif.country as country',
])
.select(sql<string>`to_json("localDateTime" at time zone 'UTC')#>>'{}'`.as('localDateTime'))
.$if(!!options.albumId, (qb) =>
qb
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.where('albums_assets_assets.albumsId', '=', options.albumId!),
eb.fn
.coalesce(
eb
.case()
.when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`)
.then(eb.lit(1.0))
.when('exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
.then(sql`round(exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, 3)`)
.else(sql`round(exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, 3)`)
.end(),
eb.lit(1.0),
)
.$if(!!options.personId, (qb) =>
qb.innerJoin(
() => hasPeopleNoJoin([options.personId!]),
(join) => join.onRef('has_people.assetId', '=', 'assets.id'),
.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
.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)]),
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')
.selectAll('asset_stack')
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.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)
.groupBy('asset_stack.id')
.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.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));
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy('assets.localDateTime', options.order ?? 'desc'),
)
.with('agg', (qb) =>
qb
.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'),
]),
),
)
.selectFrom('agg')
.select(sql<string>`to_json(agg)::text`.as('assets'));
return await query.execute();
return query.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })

View File

@ -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<TimeBucketResponseDto> {
// pre-jsonified response
async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> {
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) {

View File

@ -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)[];
};

View File

@ -1,7 +1,6 @@
import {
DeduplicateJoinsPlugin,
Expression,
expressionBuilder,
ExpressionBuilder,
ExpressionWrapper,
Kysely,
@ -211,19 +210,18 @@ export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDele
}
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) {
return qb.innerJoin(hasPeopleNoJoin(personIds), (join) => join.onRef('has_people.assetId', '=', 'assets.id'));
}
export function hasPeopleNoJoin(personIds: string[]) {
const eb = expressionBuilder<DB, never>();
return eb
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');
.as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'assets.id'),
);
}
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) {
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) {
return qb.where((eb) => withTagIdNoWhere(tagId, eb.ref('assets.id')));
}
export function withTagIdNoWhere(tagId: string, assetId: Expression<string>) {
const eb = expressionBuilder<DB, never>();
return eb.exists(
return qb.where((eb) =>
eb.exists(
eb
.selectFrom('tags_closure')
.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),
),
);
}

View File

@ -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,12 +81,10 @@ export type TimelineAsset = {
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
description: {
city: string | null;
country: string | null;
people: string[];
};
};
class IntersectingAsset {
// --- public ---
readonly #group: AssetDateGroup;
@ -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],
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: [],
},
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],
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);
}
}