mirror of
https://github.com/immich-app/immich.git
synced 2026-02-12 20:08:25 +03:00
feat(server): lighter buckets
This commit is contained in:
@@ -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 { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
|
||||
import { TimeBucketAssetDto, 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,13 @@ 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[]>;
|
||||
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 } 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;
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
import { IsEnum, IsInt, IsString, Min } from 'class-validator';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types';
|
||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TimeBucketDto {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(TimeBucketSize)
|
||||
@ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' })
|
||||
size!: TimeBucketSize;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
userId?: string;
|
||||
|
||||
@@ -46,12 +42,132 @@ export class TimeBucketDto {
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
@IsString()
|
||||
timeBucket!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Optional()
|
||||
page?: number;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class TimeBucketResponseDto {
|
||||
export class TimelineStackResponseDto implements TimelineStack {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
primaryAssetId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetResponseDto implements TimeBucketAssets {
|
||||
@ApiProperty({ type: [String] })
|
||||
id: string[] = [];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
ownerId: string[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
ratio: number[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
isFavorite: number[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
isArchived: number[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
isTrashed: number[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
isImage: number[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
isVideo: number[] = [];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
thumbhash: (string | number)[] = [];
|
||||
|
||||
@ApiProperty()
|
||||
localDateTime: Date[] = [];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
duration: (string | number)[] = [];
|
||||
|
||||
@ApiProperty({ type: [TimelineStackResponseDto] })
|
||||
stack: (TimelineStackResponseDto | number)[] = [];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
projectionType: (string | number)[] = [];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
livePhotoVideoId: (string | number)[] = [];
|
||||
}
|
||||
|
||||
export class TimeBucketsResponseDto {
|
||||
@ApiProperty({ type: 'string' })
|
||||
timeBucket!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
export class TimeBucketResponseDto {
|
||||
@ApiProperty({ type: TimeBucketAssetResponseDto })
|
||||
bucketAssets!: TimeBucketAssetResponseDto;
|
||||
|
||||
@ApiProperty()
|
||||
hasNextPage!: boolean;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
anyUuid,
|
||||
asUuid,
|
||||
hasPeople,
|
||||
hasPeopleNoJoin,
|
||||
removeUndefinedKeys,
|
||||
searchAssetBuilder,
|
||||
truncatedDate,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
withOwner,
|
||||
withSmartSearch,
|
||||
withTagId,
|
||||
withTagIdNoWhere,
|
||||
withTags,
|
||||
} from 'src/utils/database';
|
||||
import { globToSqlPattern } from 'src/utils/misc';
|
||||
@@ -80,7 +82,6 @@ export interface AssetBuilderOptions {
|
||||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
size: TimeBucketSize;
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
@@ -637,7 +638,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)
|
||||
.where('assets.isVisible', '=', true)
|
||||
@@ -679,18 +680,39 @@ export class AssetRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] })
|
||||
async getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
|
||||
return this.db
|
||||
@GenerateSql({
|
||||
params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }, { skip: -1, take: 1000 }],
|
||||
})
|
||||
async getTimeBucket(timeBucket: string, options: TimeBucketOptions, pagination: PaginationOptions) {
|
||||
const paginate = pagination.skip! >= 1 && pagination.take >= 1;
|
||||
const query = this.db
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.$call(withExif)
|
||||
.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'])
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
qb
|
||||
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
||||
.where('albums_assets_assets.albumsId', '=', options.albumId!),
|
||||
)
|
||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||
.$if(!!options.personId, (qb) =>
|
||||
qb.innerJoin(
|
||||
() => hasPeopleNoJoin([options.personId!]),
|
||||
(join) => join.onRef('has_people.assetId', '=', 'assets.id'),
|
||||
),
|
||||
)
|
||||
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
||||
@@ -720,12 +742,15 @@ export class AssetRepository {
|
||||
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!))
|
||||
.$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(options.size), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.orderBy('assets.localDateTime', options.order ?? 'desc')
|
||||
.execute();
|
||||
.$if(paginate, (qb) => qb.offset(pagination.skip!))
|
||||
.$if(paginate, (qb) => qb.limit(pagination.take + 1));
|
||||
|
||||
return await query.execute();
|
||||
}
|
||||
|
||||
@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 { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType
|
||||
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,9 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
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, () => {
|
||||
@@ -18,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],
|
||||
});
|
||||
});
|
||||
@@ -35,16 +30,24 @@ describe(TimelineService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
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(
|
||||
expect.objectContaining({
|
||||
bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }),
|
||||
}),
|
||||
);
|
||||
|
||||
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',
|
||||
});
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
{
|
||||
timeBucket: 'bucket',
|
||||
albumId: 'album-id',
|
||||
},
|
||||
{
|
||||
skip: 1,
|
||||
take: -1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the assets for a archive time bucket if user has archive.read', async () => {
|
||||
@@ -52,20 +55,26 @@ describe(TimelineService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
userId: authStub.admin.user.id,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
expect.objectContaining({
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
userIds: [authStub.admin.user.id],
|
||||
}),
|
||||
{
|
||||
skip: 1,
|
||||
take: -1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,20 +84,29 @@ describe(TimelineService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: false,
|
||||
userId: authStub.admin.user.id,
|
||||
withPartners: true,
|
||||
}),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: false,
|
||||
withPartners: true,
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
{
|
||||
timeBucket: 'bucket',
|
||||
isArchived: false,
|
||||
withPartners: true,
|
||||
userIds: [authStub.admin.user.id],
|
||||
},
|
||||
{
|
||||
skip: 1,
|
||||
take: -1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should check permissions to read tag', async () => {
|
||||
@@ -97,41 +115,27 @@ describe(TimelineService.name, () => {
|
||||
|
||||
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' })]));
|
||||
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',
|
||||
isArchived: true,
|
||||
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',
|
||||
isArchived: true,
|
||||
albumId: 'album-id',
|
||||
});
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
{
|
||||
tagId: 'tag-123',
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
},
|
||||
{
|
||||
skip: 1,
|
||||
take: -1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the assets for a library time bucket if user has library.read', async () => {
|
||||
@@ -139,25 +143,30 @@ describe(TimelineService.name, () => {
|
||||
|
||||
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(
|
||||
expect.objectContaining({
|
||||
bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||
'bucket',
|
||||
expect.objectContaining({
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
userIds: [authStub.admin.user.id],
|
||||
}),
|
||||
{
|
||||
skip: 1,
|
||||
take: -1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if withParners is true and isArchived true or undefined', async () => {
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: true,
|
||||
withPartners: true,
|
||||
@@ -167,7 +176,6 @@ describe(TimelineService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isArchived: undefined,
|
||||
withPartners: true,
|
||||
@@ -179,7 +187,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,
|
||||
@@ -189,7 +196,6 @@ describe(TimelineService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
isFavorite: false,
|
||||
withPartners: true,
|
||||
@@ -201,7 +207,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,30 +1,105 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { round } from 'lodash';
|
||||
import { Stack } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import {
|
||||
TimeBucketAssetDto,
|
||||
TimeBucketDto,
|
||||
TimeBucketResponseDto,
|
||||
TimeBucketsResponseDto,
|
||||
} from 'src/dtos/time-bucket.dto';
|
||||
import { AssetType, Permission } from 'src/enum';
|
||||
import { TimeBucketOptions } from 'src/repositories/asset.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||
import { TimeBucketAssets } from 'src/services/timeline.service.types';
|
||||
import { getMyPartnerIds, isFlipped } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
async getTimeBucket(
|
||||
auth: AuthDto,
|
||||
dto: TimeBucketAssetDto,
|
||||
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
|
||||
async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<TimeBucketResponseDto> {
|
||||
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 });
|
||||
|
||||
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: [],
|
||||
};
|
||||
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(item.thumbhash ? hexOrBufferToBase64(item.thumbhash) : 0);
|
||||
bucketAssets.localDateTime.push(item.localDateTime);
|
||||
bucketAssets.stack.push(this.mapStack(item.stack) || 0);
|
||||
bucketAssets.duration.push(item.duration || 0);
|
||||
bucketAssets.projectionType.push(item.projectionType || 0);
|
||||
bucketAssets.livePhotoVideoId.push(item.livePhotoVideoId || 0);
|
||||
bucketAssets.isImage.push(item.type === AssetType.IMAGE ? 1 : 0);
|
||||
bucketAssets.isVideo.push(item.type === AssetType.VIDEO ? 1 : 0);
|
||||
}
|
||||
|
||||
return {
|
||||
bucketAssets,
|
||||
hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
mapStack(entity?: Stack | null) {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entity.id!,
|
||||
primaryAssetId: entity.primaryAssetId!,
|
||||
assetCount: entity.assetCount as number,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
|
||||
|
||||
22
server/src/services/timeline.service.types.ts
Normal file
22
server/src/services/timeline.service.types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TimelineStack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export type TimeBucketAssets = {
|
||||
id: string[];
|
||||
ownerId: string[];
|
||||
ratio: number[];
|
||||
isFavorite: number[];
|
||||
isArchived: number[];
|
||||
isTrashed: number[];
|
||||
isVideo: number[];
|
||||
isImage: number[];
|
||||
thumbhash: (string | number)[];
|
||||
localDateTime: Date[];
|
||||
stack: (TimelineStack | number)[];
|
||||
duration: (string | number)[];
|
||||
projectionType: (string | number)[];
|
||||
livePhotoVideoId: (string | number)[];
|
||||
};
|
||||
@@ -197,3 +197,16 @@ export const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
|
||||
file: mapToUploadFile(file as ImmichFile),
|
||||
};
|
||||
};
|
||||
|
||||
function isRotated90CW(orientation: number) {
|
||||
return orientation === 5 || orientation === 6 || orientation === 90;
|
||||
}
|
||||
|
||||
function isRotated270CW(orientation: number) {
|
||||
return orientation === 7 || orientation === 8 || orientation === -90;
|
||||
}
|
||||
|
||||
export function isFlipped(orientation?: string | null) {
|
||||
const value = Number(orientation);
|
||||
return value && (isRotated270CW(value) || isRotated90CW(value));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DeduplicateJoinsPlugin,
|
||||
Expression,
|
||||
expressionBuilder,
|
||||
ExpressionBuilder,
|
||||
ExpressionWrapper,
|
||||
Kysely,
|
||||
@@ -180,18 +181,19 @@ export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDele
|
||||
}
|
||||
|
||||
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) {
|
||||
return qb.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset_faces')
|
||||
.select('assetId')
|
||||
.where('personId', '=', anyUuid(personIds!))
|
||||
.where('deletedAt', 'is', null)
|
||||
.groupBy('assetId')
|
||||
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
||||
.as('has_people'),
|
||||
(join) => join.onRef('has_people.assetId', '=', 'assets.id'),
|
||||
);
|
||||
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
|
||||
.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');
|
||||
}
|
||||
|
||||
export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: string[]) {
|
||||
@@ -236,16 +238,20 @@ export function truncatedDate<O>(size: TimeBucketSize) {
|
||||
}
|
||||
|
||||
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
|
||||
return qb.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('tags_closure')
|
||||
.innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
|
||||
.whereRef('tag_asset.assetsId', '=', 'assets.id')
|
||||
.where('tags_closure.id_ancestor', '=', tagId),
|
||||
),
|
||||
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(
|
||||
eb
|
||||
.selectFrom('tags_closure')
|
||||
.innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
|
||||
.whereRef('tag_asset.assetsId', '=', assetId)
|
||||
.where('tags_closure.id_ancestor', '=', tagId),
|
||||
);
|
||||
}
|
||||
|
||||
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
||||
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
||||
|
||||
|
||||
@@ -13,7 +13,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
@@ -257,6 +257,10 @@ export const assetStub = {
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
stack: null,
|
||||
orientation: '',
|
||||
projectionType: null,
|
||||
height: 3840,
|
||||
width: 2160,
|
||||
}),
|
||||
|
||||
trashed: Object.freeze({
|
||||
|
||||
Reference in New Issue
Block a user