mirror of
https://github.com/immich-app/immich.git
synced 2025-07-08 10:44:15 -04:00
push aggregation to query
This commit is contained in:
parent
07c03b8a79
commit
97cc9e223e
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
.selectFrom('assets')
|
.with('cte', (qb) =>
|
||||||
.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<string>`to_json("localDateTime" at time zone 'UTC')#>>'{}'`.as('localDateTime'))
|
|
||||||
.$if(!!options.albumId, (qb) =>
|
|
||||||
qb
|
qb
|
||||||
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
.selectFrom('assets')
|
||||||
.where('albums_assets_assets.albumsId', '=', options.albumId!),
|
.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<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),
|
||||||
|
)
|
||||||
|
.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) =>
|
.with('agg', (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) =>
|
|
||||||
qb
|
qb
|
||||||
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
|
.selectFrom('cte')
|
||||||
.where((eb) =>
|
.select((eb) => [
|
||||||
eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]),
|
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
|
||||||
)
|
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
|
||||||
.leftJoinLateral(
|
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
|
||||||
(eb) =>
|
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
|
||||||
eb
|
eb.fn.coalesce(eb.fn('array_agg', ['isArchived']), sql.lit('{}')).as('isArchived'),
|
||||||
.selectFrom('assets as stacked')
|
eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'),
|
||||||
.selectAll('asset_stack')
|
eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'),
|
||||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
// TODO: isTrashed is redundant as it will always be all 0s or 1s depending on the options
|
||||||
.whereRef('stacked.stackId', '=', 'asset_stack.id')
|
eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
|
||||||
.where('stacked.deletedAt', 'is', null)
|
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
|
||||||
.where('stacked.isArchived', '=', false)
|
eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'),
|
||||||
.groupBy('asset_stack.id')
|
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
|
||||||
.as('stacked_assets'),
|
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
|
||||||
(join) => join.on('asset_stack.id', 'is not', null),
|
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
|
||||||
)
|
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
|
||||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')),
|
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!))
|
.selectFrom('agg')
|
||||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
.select(sql<string>`to_json(agg)::text`.as('assets'));
|
||||||
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));
|
|
||||||
|
|
||||||
return await query.execute();
|
return query.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
@ -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) {
|
||||||
|
@ -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)[];
|
||||||
};
|
};
|
||||||
|
@ -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[]) {
|
.selectFrom('asset_faces')
|
||||||
const eb = expressionBuilder<DB, never>();
|
.select('assetId')
|
||||||
return eb
|
.where('personId', '=', anyUuid(personIds!))
|
||||||
.selectFrom('asset_faces')
|
.where('deletedAt', 'is', null)
|
||||||
.select('assetId')
|
.groupBy('assetId')
|
||||||
.where('personId', '=', anyUuid(personIds!))
|
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
||||||
.where('deletedAt', 'is', null)
|
.as('has_people'),
|
||||||
.groupBy('assetId')
|
(join) => join.onRef('has_people.assetId', '=', 'assets.id'),
|
||||||
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
);
|
||||||
.as('has_people');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
eb
|
||||||
export function withTagIdNoWhere(tagId: string, assetId: Expression<string>) {
|
.selectFrom('tags_closure')
|
||||||
const eb = expressionBuilder<DB, never>();
|
.innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
|
||||||
return eb.exists(
|
.whereRef('tag_asset.assetsId', '=', 'assets.id')
|
||||||
eb
|
.where('tags_closure.id_ancestor', '=', tagId),
|
||||||
.selectFrom('tags_closure')
|
),
|
||||||
.innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
|
|
||||||
.whereRef('tag_asset.assetsId', '=', assetId)
|
|
||||||
.where('tags_closure.id_ancestor', '=', tagId),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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],
|
||||||
people: [],
|
duration: bucketAssets.duration[i],
|
||||||
},
|
id: bucketAssets.id[i],
|
||||||
duration: bucketResponse.bucketAssets.duration[i],
|
isArchived: Boolean(bucketAssets.isArchived[i]),
|
||||||
id: bucketResponse.bucketAssets.id[i],
|
isFavorite: Boolean(bucketAssets.isFavorite[i]),
|
||||||
isArchived: !!bucketResponse.bucketAssets.isArchived[i],
|
isImage: Boolean(bucketAssets.isImage[i]),
|
||||||
isFavorite: !!bucketResponse.bucketAssets.isFavorite[i],
|
isTrashed: Boolean(bucketAssets.isTrashed[i]),
|
||||||
isImage: !!bucketResponse.bucketAssets.isImage[i],
|
isVideo: !bucketAssets.isImage[i],
|
||||||
isTrashed: !!bucketResponse.bucketAssets.isTrashed[i],
|
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||||
isVideo: !!bucketResponse.bucketAssets.isVideo[i],
|
localDateTime: bucketAssets.localDateTime[i],
|
||||||
livePhotoVideoId: bucketResponse.bucketAssets.livePhotoVideoId[i],
|
ownerId: bucketAssets.ownerId[i],
|
||||||
localDateTime: bucketResponse.bucketAssets.localDateTime[i],
|
people: [],
|
||||||
ownerId: bucketResponse.bucketAssets.ownerId[i],
|
projectionType: bucketAssets.projectionType[i],
|
||||||
projectionType: bucketResponse.bucketAssets.projectionType[i],
|
ratio: bucketAssets.ratio[i],
|
||||||
ratio: bucketResponse.bucketAssets.ratio[i],
|
stack: bucketAssets.stackId?.[i]
|
||||||
stack: bucketResponse.bucketAssets.stack[i],
|
? {
|
||||||
thumbhash: bucketResponse.bucketAssets.thumbhash[i],
|
id: bucketAssets.stackId[i]!,
|
||||||
|
primaryAssetId: bucketAssets.id[i],
|
||||||
|
assetCount: bucketAssets.stackCount![i],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
thumbhash: 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user