mirror of
https://github.com/immich-app/immich.git
synced 2025-06-12 21:38:37 +02:00
feat(server): lighter buckets (#17831)
* feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * feat(server): lighter buckets * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * Adapt web client to consume new server response format * test * missing import * lint * Use nulls, make-sql * openapi battle * date->string * tests * tests * lint/tests * lint * test * push aggregation to query * openapi * stack as tuple * openapi * update references to description * update alt text tests * update sql * update sql * update timeline tests * linting, fix expected response * string tuple * fix spec * fix * silly generator * rename patch * minimize sorting * review * lint * lint * sql * test * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * remove unncessary code * review comments * sql * re-add package-lock * use booleans, fix visibility in openapi spec, less cursed controller * update sql * no need to use sql template * array access actually doesn't seem to matter * remove redundant code * re-add sql decorator * unused type * remove null assertions * bad merge * Fix test * shave * extra clean shave * use decorator for content type * redundant types * redundant comment * update comment * unnecessary res --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
59f666b115
commit
e7edbcdf04
41 changed files with 1109 additions and 510 deletions
server
src
bin
controllers
dtos
queries
repositories
services
utils
workers
test/fixtures
|
@ -72,7 +72,9 @@ class SqlGenerator {
|
|||
await rm(this.options.targetDir, { force: true, recursive: true });
|
||||
await mkdir(this.options.targetDir);
|
||||
|
||||
process.env.DB_HOSTNAME = 'localhost';
|
||||
if (!process.env.DB_HOSTNAME) {
|
||||
process.env.DB_HOSTNAME = 'localhost';
|
||||
}
|
||||
const { database, cls, otel } = new ConfigRepository().getEnv();
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { Controller, Get, Header, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
|
||||
import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
|
@ -14,13 +13,15 @@ export class TimelineController {
|
|||
|
||||
@Get('buckets')
|
||||
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
|
||||
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
|
||||
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) {
|
||||
return this.service.getTimeBuckets(auth, dto);
|
||||
}
|
||||
|
||||
@Get('bucket')
|
||||
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
|
||||
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
|
||||
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>;
|
||||
@ApiOkResponse({ type: TimeBucketAssetResponseDto })
|
||||
@Header('Content-Type', 'application/json')
|
||||
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) {
|
||||
return this.service.getTimeBucket(auth, dto);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
export class SanitizedAssetResponseDto {
|
||||
|
@ -140,15 +141,6 @@ const mapStack = (entity: { stack?: Stack | null }) => {
|
|||
};
|
||||
};
|
||||
|
||||
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
||||
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
||||
if (typeof encoded === 'string') {
|
||||
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
|
||||
}
|
||||
|
||||
return encoded.toString('base64');
|
||||
};
|
||||
|
||||
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
|
@ -192,7 +184,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||
tags: entity.tags?.map((tag) => mapTag(tag)),
|
||||
people: peopleWithFaces(entity.faces),
|
||||
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
||||
checksum: hexOrBufferToBase64(entity.checksum),
|
||||
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||
stack: withStack ? mapStack(entity) : undefined,
|
||||
isOffline: entity.isOffline,
|
||||
hasMetadata: true,
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
import { IsEnum, IsInt, IsString, Min } from 'class-validator';
|
||||
import { AssetOrder, AssetVisibility } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TimeBucketDto {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(TimeBucketSize)
|
||||
@ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' })
|
||||
size!: TimeBucketSize;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
userId?: string;
|
||||
|
||||
|
@ -46,9 +41,75 @@ export class TimeBucketDto {
|
|||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
@IsString()
|
||||
timeBucket!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Optional()
|
||||
page?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Optional()
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class TimeBucketResponseDto {
|
||||
export class TimelineStackResponseDto {
|
||||
id!: string;
|
||||
primaryAssetId!: string;
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetResponseDto {
|
||||
id!: string[];
|
||||
|
||||
ownerId!: string[];
|
||||
|
||||
ratio!: number[];
|
||||
|
||||
isFavorite!: boolean[];
|
||||
|
||||
@ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true })
|
||||
visibility!: AssetVisibility[];
|
||||
|
||||
isTrashed!: boolean[];
|
||||
|
||||
isImage!: boolean[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||
thumbhash!: (string | null)[];
|
||||
|
||||
localDateTime!: string[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||
duration!: (string | null)[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
nullable: true,
|
||||
},
|
||||
description: '(stack ID, stack asset count) tuple',
|
||||
})
|
||||
stack?: ([string, string] | null)[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||
projectionType!: (string | null)[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||
livePhotoVideoId!: (string | null)[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||
city!: (string | null)[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||
country!: (string | null)[];
|
||||
}
|
||||
|
||||
export class TimeBucketsResponseDto {
|
||||
@ApiProperty({ type: 'string' })
|
||||
timeBucket!: string;
|
||||
|
||||
|
|
|
@ -235,14 +235,14 @@ limit
|
|||
with
|
||||
"assets" as (
|
||||
select
|
||||
date_trunc($1, "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
|
||||
date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
"assets"."deletedAt" is null
|
||||
and (
|
||||
"assets"."visibility" = $2
|
||||
or "assets"."visibility" = $3
|
||||
"assets"."visibility" = $1
|
||||
or "assets"."visibility" = $2
|
||||
)
|
||||
)
|
||||
select
|
||||
|
@ -256,40 +256,101 @@ order by
|
|||
"timeBucket" desc
|
||||
|
||||
-- AssetRepository.getTimeBucket
|
||||
select
|
||||
"assets".*,
|
||||
to_json("exif") as "exifInfo",
|
||||
to_json("stacked_assets") as "stack"
|
||||
from
|
||||
"assets"
|
||||
left join "exif" on "assets"."id" = "exif"."assetId"
|
||||
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
|
||||
left join lateral (
|
||||
with
|
||||
"cte" as (
|
||||
select
|
||||
"asset_stack".*,
|
||||
count("stacked") as "assetCount"
|
||||
"assets"."duration",
|
||||
"assets"."id",
|
||||
"assets"."visibility",
|
||||
"assets"."isFavorite",
|
||||
assets.type = 'IMAGE' as "isImage",
|
||||
assets."deletedAt" is null as "isTrashed",
|
||||
"assets"."livePhotoVideoId",
|
||||
"assets"."localDateTime",
|
||||
"assets"."ownerId",
|
||||
"assets"."status",
|
||||
encode("assets"."thumbhash", 'base64') as "thumbhash",
|
||||
"exif"."city",
|
||||
"exif"."country",
|
||||
"exif"."projectionType",
|
||||
coalesce(
|
||||
case
|
||||
when exif."exifImageHeight" = 0
|
||||
or exif."exifImageWidth" = 0 then 1
|
||||
when "exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
|
||||
exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric,
|
||||
3
|
||||
)
|
||||
else round(
|
||||
exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric,
|
||||
3
|
||||
)
|
||||
end,
|
||||
1
|
||||
) as "ratio",
|
||||
"stack"
|
||||
from
|
||||
"assets" as "stacked"
|
||||
"assets"
|
||||
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||
left join lateral (
|
||||
select
|
||||
array[stacked."stackId"::text, count('stacked')::text] as "stack"
|
||||
from
|
||||
"assets" as "stacked"
|
||||
where
|
||||
"stacked"."stackId" = "assets"."stackId"
|
||||
and "stacked"."deletedAt" is null
|
||||
and "stacked"."visibility" != $1
|
||||
group by
|
||||
"stacked"."stackId"
|
||||
) as "stacked_assets" on true
|
||||
where
|
||||
"stacked"."stackId" = "asset_stack"."id"
|
||||
and "stacked"."deletedAt" is null
|
||||
and "stacked"."visibility" != $1
|
||||
group by
|
||||
"asset_stack"."id"
|
||||
) as "stacked_assets" on "asset_stack"."id" is not null
|
||||
where
|
||||
(
|
||||
"asset_stack"."primaryAssetId" = "assets"."id"
|
||||
or "assets"."stackId" is null
|
||||
"assets"."deletedAt" is null
|
||||
and (
|
||||
"assets"."visibility" = $2
|
||||
or "assets"."visibility" = $3
|
||||
)
|
||||
and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
|
||||
and (
|
||||
"assets"."visibility" = $5
|
||||
or "assets"."visibility" = $6
|
||||
)
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"asset_stack"
|
||||
where
|
||||
"asset_stack"."id" = "assets"."stackId"
|
||||
and "asset_stack"."primaryAssetId" != "assets"."id"
|
||||
)
|
||||
order by
|
||||
"assets"."localDateTime" desc
|
||||
),
|
||||
"agg" as (
|
||||
select
|
||||
coalesce(array_agg("city"), '{}') as "city",
|
||||
coalesce(array_agg("country"), '{}') as "country",
|
||||
coalesce(array_agg("duration"), '{}') as "duration",
|
||||
coalesce(array_agg("id"), '{}') as "id",
|
||||
coalesce(array_agg("visibility"), '{}') as "visibility",
|
||||
coalesce(array_agg("isFavorite"), '{}') as "isFavorite",
|
||||
coalesce(array_agg("isImage"), '{}') as "isImage",
|
||||
coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
|
||||
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
|
||||
coalesce(array_agg("localDateTime"), '{}') as "localDateTime",
|
||||
coalesce(array_agg("ownerId"), '{}') as "ownerId",
|
||||
coalesce(array_agg("projectionType"), '{}') as "projectionType",
|
||||
coalesce(array_agg("ratio"), '{}') as "ratio",
|
||||
coalesce(array_agg("status"), '{}') as "status",
|
||||
coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
|
||||
coalesce(json_agg("stack"), '[]') as "stack"
|
||||
from
|
||||
"cte"
|
||||
)
|
||||
and "assets"."deletedAt" is null
|
||||
and (
|
||||
"assets"."visibility" = $2
|
||||
or "assets"."visibility" = $3
|
||||
)
|
||||
and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5
|
||||
order by
|
||||
"assets"."localDateTime" desc
|
||||
select
|
||||
to_json(agg)::text as "assets"
|
||||
from
|
||||
"agg"
|
||||
|
||||
-- AssetRepository.getDuplicates
|
||||
with
|
||||
|
|
|
@ -68,7 +68,6 @@ export interface AssetBuilderOptions {
|
|||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
size: TimeBucketSize;
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
|
@ -539,7 +538,7 @@ export class AssetRepository {
|
|||
.with('assets', (qb) =>
|
||||
qb
|
||||
.selectFrom('assets')
|
||||
.select(truncatedDate<Date>(options.size).as('timeBucket'))
|
||||
.select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility === undefined, withDefaultVisibility)
|
||||
|
@ -581,53 +580,126 @@ export class AssetRepository {
|
|||
);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] })
|
||||
async getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.$call(withExif)
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
@GenerateSql({
|
||||
params: [DummyValue.TIME_BUCKET, { withStacked: true }],
|
||||
})
|
||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
|
||||
const query = this.db
|
||||
.with('cte', (qb) =>
|
||||
qb
|
||||
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
||||
.where('albums_assets_assets.albumsId', '=', options.albumId!),
|
||||
.selectFrom('assets')
|
||||
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
||||
.select((eb) => [
|
||||
'assets.duration',
|
||||
'assets.id',
|
||||
'assets.visibility',
|
||||
'assets.isFavorite',
|
||||
sql`assets.type = 'IMAGE'`.as('isImage'),
|
||||
sql`assets."deletedAt" is null`.as('isTrashed'),
|
||||
'assets.livePhotoVideoId',
|
||||
'assets.localDateTime',
|
||||
'assets.ownerId',
|
||||
'assets.status',
|
||||
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
|
||||
'exif.city',
|
||||
'exif.country',
|
||||
'exif.projectionType',
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb
|
||||
.case()
|
||||
.when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`)
|
||||
.then(eb.lit(1))
|
||||
.when('exif.orientation', 'in', sql<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),
|
||||
)
|
||||
.as('ratio'),
|
||||
])
|
||||
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
||||
.where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('albums_assets_assets')
|
||||
.whereRef('albums_assets_assets.assetsId', '=', 'assets.id')
|
||||
.where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
||||
.$if(!!options.withStacked, (qb) =>
|
||||
qb
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('asset_stack')
|
||||
.whereRef('asset_stack.id', '=', 'assets.stackId')
|
||||
.whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('assets as stacked')
|
||||
.select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack'))
|
||||
.whereRef('stacked.stackId', '=', 'assets.stackId')
|
||||
.where('stacked.deletedAt', 'is', null)
|
||||
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
|
||||
.groupBy('stacked.stackId')
|
||||
.as('stacked_assets'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select('stack'),
|
||||
)
|
||||
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
|
||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
|
||||
)
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||
.orderBy('assets.localDateTime', options.order ?? 'desc'),
|
||||
)
|
||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
||||
.$if(!!options.withStacked, (qb) =>
|
||||
.with('agg', (qb) =>
|
||||
qb
|
||||
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
|
||||
.where((eb) =>
|
||||
eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]),
|
||||
)
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('assets as stacked')
|
||||
.selectAll('asset_stack')
|
||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
||||
.whereRef('stacked.stackId', '=', 'asset_stack.id')
|
||||
.where('stacked.deletedAt', 'is', null)
|
||||
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
|
||||
.groupBy('asset_stack.id')
|
||||
.as('stacked_assets'),
|
||||
(join) => join.on('asset_stack.id', 'is not', null),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')),
|
||||
.selectFrom('cte')
|
||||
.select((eb) => [
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'),
|
||||
// TODO: isTrashed is redundant as it will always be all true or false depending on the options
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
|
||||
])
|
||||
.$if(!!options.withStacked, (qb) =>
|
||||
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
|
||||
),
|
||||
)
|
||||
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
|
||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
|
||||
)
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
||||
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.orderBy('assets.localDateTime', options.order ?? 'desc')
|
||||
.execute();
|
||||
.selectFrom('agg')
|
||||
.select(sql<string>`to_json(agg)::text`.as('assets'));
|
||||
|
||||
return query.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
|
|
|
@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
|
|||
import { Writable } from 'node:stream';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { SessionSyncCheckpoints } from 'src/db';
|
||||
import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetDeltaSyncDto,
|
||||
|
@ -18,6 +18,7 @@ import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType
|
|||
import { BaseService } from 'src/services/base.service';
|
||||
import { SyncAck } from 'src/types';
|
||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { setIsEqual } from 'src/utils/set';
|
||||
import { fromAck, serialize } from 'src/utils/sync';
|
||||
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(TimelineService.name, () => {
|
||||
|
@ -19,13 +16,10 @@ describe(TimelineService.name, () => {
|
|||
it("should return buckets if userId and albumId aren't set", async () => {
|
||||
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
|
||||
|
||||
await expect(
|
||||
sut.getTimeBuckets(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
|
||||
await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
|
||||
expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
|
||||
);
|
||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
||||
size: TimeBucketSize.DAY,
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
});
|
||||
|
@ -34,35 +28,34 @@ describe(TimelineService.name, () => {
|
|||
describe('getTimeBucket', () => {
|
||||
it('should return the assets for a album time bucket if user has album.read', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
const json = `[{ id: ['asset-id'] }]`;
|
||||
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual(
|
||||
json,
|
||||
);
|
||||
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
albumId: 'album-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the assets for a archive time bucket if user has archive.read', async () => {
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
const json = `[{ id: ['asset-id'] }]`;
|
||||
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.ARCHIVE,
|
||||
userId: authStub.admin.user.id,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
).resolves.toEqual(json);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
expect.objectContaining({
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.ARCHIVE,
|
||||
userIds: [authStub.admin.user.id],
|
||||
|
@ -71,20 +64,19 @@ describe(TimelineService.name, () => {
|
|||
});
|
||||
|
||||
it('should include partner shared assets', async () => {
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
const json = `[{ id: ['asset-id'] }]`;
|
||||
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.TIMELINE,
|
||||
userId: authStub.admin.user.id,
|
||||
withPartners: true,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
).resolves.toEqual(json);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.TIMELINE,
|
||||
withPartners: true,
|
||||
|
@ -93,62 +85,37 @@ describe(TimelineService.name, () => {
|
|||
});
|
||||
|
||||
it('should check permissions to read tag', async () => {
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
const json = `[{ id: ['asset-id'] }]`;
|
||||
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userId: authStub.admin.user.id,
|
||||
tagId: 'tag-123',
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
).resolves.toEqual(json);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
tagId: 'tag-123',
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip metadata if showExif is disabled', async () => {
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
const auth = factory.auth({ sharedLink: { showExif: false } });
|
||||
|
||||
const buckets = await sut.getTimeBucket(auth, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.ARCHIVE,
|
||||
albumId: 'album-id',
|
||||
});
|
||||
|
||||
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
|
||||
expect(buckets[0]).not.toHaveProperty('exif');
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.ARCHIVE,
|
||||
albumId: 'album-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the assets for a library time bucket if user has library.read', async () => {
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
const json = `[{ id: ['asset-id'] }]`;
|
||||
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userId: authStub.admin.user.id,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
).resolves.toEqual(json);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
expect.objectContaining({
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
}),
|
||||
|
@ -158,7 +125,6 @@ describe(TimelineService.name, () => {
|
|||
it('should throw an error if withParners is true and visibility true or undefined', async () => {
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.ARCHIVE,
|
||||
withPartners: true,
|
||||
|
@ -168,7 +134,6 @@ describe(TimelineService.name, () => {
|
|||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
visibility: undefined,
|
||||
withPartners: true,
|
||||
|
@ -180,7 +145,6 @@ describe(TimelineService.name, () => {
|
|||
it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isFavorite: true,
|
||||
withPartners: true,
|
||||
|
@ -190,7 +154,6 @@ describe(TimelineService.name, () => {
|
|||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isFavorite: false,
|
||||
withPartners: true,
|
||||
|
@ -202,7 +165,6 @@ describe(TimelineService.name, () => {
|
|||
it('should throw an error if withParners is true and isTrash is true', async () => {
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isTrashed: true,
|
||||
withPartners: true,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
|
||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto';
|
||||
import { AssetVisibility, Permission } from 'src/enum';
|
||||
import { TimeBucketOptions } from 'src/repositories/asset.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
@ -9,22 +8,20 @@ import { getMyPartnerIds } from 'src/utils/asset.util';
|
|||
|
||||
@Injectable()
|
||||
export class TimelineService extends BaseService {
|
||||
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
|
||||
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
|
||||
await this.timeBucketChecks(auth, dto);
|
||||
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
||||
return this.assetRepository.getTimeBuckets(timeBucketOptions);
|
||||
return await this.assetRepository.getTimeBuckets(timeBucketOptions);
|
||||
}
|
||||
|
||||
async getTimeBucket(
|
||||
auth: AuthDto,
|
||||
dto: TimeBucketAssetDto,
|
||||
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
|
||||
// pre-jsonified response
|
||||
async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> {
|
||||
await this.timeBucketChecks(auth, dto);
|
||||
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
||||
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
|
||||
return !auth.sharedLink || auth.sharedLink?.showExif
|
||||
? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
|
||||
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
|
||||
const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto });
|
||||
|
||||
// TODO: use id cursor for pagination
|
||||
const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
|
||||
return bucket.assets;
|
||||
}
|
||||
|
||||
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
|
||||
|
|
|
@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string {
|
|||
|
||||
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
|
||||
}
|
||||
|
||||
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
||||
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
||||
if (typeof encoded === 'string') {
|
||||
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
|
||||
}
|
||||
|
||||
return encoded.toString('base64');
|
||||
};
|
||||
|
|
|
@ -271,7 +271,7 @@ 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) {
|
||||
|
@ -285,6 +285,7 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: str
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
||||
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { isStartUpError, useSwagger } from 'src/utils/misc';
|
||||
|
||||
async function bootstrap() {
|
||||
process.title = 'immich-api';
|
||||
|
||||
|
|
4
server/test/fixtures/asset.stub.ts
vendored
4
server/test/fixtures/asset.stub.ts
vendored
|
@ -251,6 +251,10 @@ export const assetStub = {
|
|||
duplicateId: null,
|
||||
isOffline: false,
|
||||
stack: null,
|
||||
orientation: '',
|
||||
projectionType: null,
|
||||
height: 3840,
|
||||
width: 2160,
|
||||
visibility: AssetVisibility.TIMELINE,
|
||||
}),
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue