mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 18:39:54 +03:00
feat: getAssetEdits respond with edit IDs (#26445)
* feat: getAssetEdits respond with edit IDs * chore: cleanup typings for edit API * chore: cleanup types with jason * fix: openapi sync * fix: factory
This commit is contained in:
@@ -369,6 +369,31 @@ describe(AssetController.name, () => {
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should check the action and parameters discriminator', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({
|
||||
edits: [
|
||||
{
|
||||
action: 'rotate',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require at least one edit', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
UpdateAssetDto,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
@@ -235,7 +235,7 @@ export class AssetController {
|
||||
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> {
|
||||
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsResponseDto> {
|
||||
return this.service.getAssetEdits(auth, id);
|
||||
}
|
||||
|
||||
@@ -249,8 +249,8 @@ export class AssetController {
|
||||
editAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: AssetEditActionListDto,
|
||||
): Promise<AssetEditsDto> {
|
||||
@Body() dto: AssetEditsCreateDto,
|
||||
): Promise<AssetEditsResponseDto> {
|
||||
return this.service.editAsset(auth, id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
|
||||
import { ExtraModel } from 'src/dtos/sync.dto';
|
||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetEditAction {
|
||||
Crop = 'crop',
|
||||
@@ -14,6 +15,7 @@ export enum MirrorAxis {
|
||||
Vertical = 'vertical',
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class CropParameters {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@@ -36,48 +38,21 @@ export class CropParameters {
|
||||
height!: number;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class RotateParameters {
|
||||
@IsAxisAlignedRotation()
|
||||
@ApiProperty({ description: 'Rotation angle in degrees' })
|
||||
angle!: number;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class MirrorParameters {
|
||||
@IsEnum(MirrorAxis)
|
||||
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
|
||||
axis!: MirrorAxis;
|
||||
}
|
||||
|
||||
class AssetEditActionBase {
|
||||
@IsEnum(AssetEditAction)
|
||||
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' })
|
||||
action!: AssetEditAction;
|
||||
}
|
||||
|
||||
export class AssetEditActionCrop extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => CropParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: CropParameters;
|
||||
}
|
||||
|
||||
export class AssetEditActionRotate extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => RotateParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: RotateParameters;
|
||||
}
|
||||
|
||||
export class AssetEditActionMirror extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => MirrorParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: MirrorParameters;
|
||||
}
|
||||
|
||||
export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters;
|
||||
export type AssetEditActionItem =
|
||||
| {
|
||||
action: AssetEditAction.Crop;
|
||||
@@ -92,47 +67,48 @@ export type AssetEditActionItem =
|
||||
parameters: MirrorParameters;
|
||||
};
|
||||
|
||||
export type AssetEditActionParameter = {
|
||||
[AssetEditAction.Crop]: CropParameters;
|
||||
[AssetEditAction.Rotate]: RotateParameters;
|
||||
[AssetEditAction.Mirror]: MirrorParameters;
|
||||
export class AssetEditActionItemDto {
|
||||
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
|
||||
action!: AssetEditAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of edit actions to apply (crop, rotate, or mirror)',
|
||||
anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({
|
||||
$ref: getSchemaPath(type),
|
||||
})),
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter])
|
||||
parameters!: AssetEditActionItem['parameters'];
|
||||
}
|
||||
|
||||
export class AssetEditActionItemResponseDto extends AssetEditActionItemDto {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export type AssetEditActionParameter = typeof actionParameterMap;
|
||||
const actionParameterMap = {
|
||||
[AssetEditAction.Crop]: CropParameters,
|
||||
[AssetEditAction.Rotate]: RotateParameters,
|
||||
[AssetEditAction.Mirror]: MirrorParameters,
|
||||
};
|
||||
|
||||
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
|
||||
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
|
||||
[AssetEditAction.Crop]: AssetEditActionCrop,
|
||||
[AssetEditAction.Rotate]: AssetEditActionRotate,
|
||||
[AssetEditAction.Mirror]: AssetEditActionMirror,
|
||||
} as const;
|
||||
|
||||
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
|
||||
actionToClass[item.action];
|
||||
|
||||
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
|
||||
export class AssetEditActionListDto {
|
||||
/** list of edits */
|
||||
export class AssetEditsCreateDto {
|
||||
@ArrayMinSize(1)
|
||||
@IsUniqueEditActions()
|
||||
@ValidateNested({ each: true })
|
||||
@Transform(({ value: edits }) =>
|
||||
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits,
|
||||
)
|
||||
@ApiProperty({
|
||||
items: {
|
||||
anyOf: Object.values(actionToClass).map((type) => ({ $ref: getSchemaPath(type) })),
|
||||
discriminator: {
|
||||
propertyName: 'action',
|
||||
mapping: Object.fromEntries(
|
||||
Object.entries(actionToClass).map(([action, type]) => [action, getSchemaPath(type)]),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: 'List of edit actions to apply (crop, rotate, or mirror)',
|
||||
})
|
||||
edits!: AssetEditActionItem[];
|
||||
@Type(() => AssetEditActionItemDto)
|
||||
@ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' })
|
||||
edits!: AssetEditActionItemDto[];
|
||||
}
|
||||
|
||||
export class AssetEditsDto extends AssetEditActionListDto {
|
||||
@ValidateUUID({ description: 'Asset ID to apply edits to' })
|
||||
export class AssetEditsResponseDto {
|
||||
@ValidateUUID({ description: 'Asset ID these edits belong to' })
|
||||
assetId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of edit actions applied to the asset',
|
||||
})
|
||||
edits!: AssetEditActionItemResponseDto[];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ rollback
|
||||
|
||||
-- AssetEditRepository.getAll
|
||||
select
|
||||
"id",
|
||||
"action",
|
||||
"parameters"
|
||||
from
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
|
||||
import { DB } from 'src/schema';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,7 +12,7 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
|
||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
|
||||
|
||||
@@ -20,8 +20,8 @@ export class AssetEditRepository {
|
||||
return trx
|
||||
.insertInto('asset_edit')
|
||||
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
|
||||
.returning(['action', 'parameters'])
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
.returning(['id', 'action', 'parameters'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -31,12 +31,12 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
getAll(assetId: string): Promise<AssetEditActionItem[]> {
|
||||
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
|
||||
return this.db
|
||||
.selectFrom('asset_edit')
|
||||
.select(['action', 'parameters'])
|
||||
.select(['id', 'action', 'parameters'])
|
||||
.where('assetId', '=', assetId)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Table,
|
||||
Unique,
|
||||
} from '@immich/sql-tools';
|
||||
import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto';
|
||||
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
@Unique({ columns: ['assetId', 'sequence'] })
|
||||
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
export class AssetEditTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@@ -29,10 +29,10 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
assetId!: string;
|
||||
|
||||
@Column()
|
||||
action!: T;
|
||||
action!: AssetEditAction;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
parameters!: AssetEditActionParameter[T];
|
||||
parameters!: AssetEditParameters;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
sequence!: number;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, AssetEditActionItem, AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -543,7 +543,7 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsDto> {
|
||||
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
const edits = await this.assetEditRepository.getAll(id);
|
||||
return {
|
||||
@@ -552,7 +552,7 @@ export class AssetService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise<AssetEditsDto> {
|
||||
async editAsset(auth: AuthDto, id: string, dto: AssetEditsCreateDto): Promise<AssetEditsResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForEdit(id);
|
||||
@@ -587,12 +587,13 @@ export class AssetService extends BaseService {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
}
|
||||
|
||||
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
|
||||
if (cropIndex > 0) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
|
||||
const edits = dto.edits as AssetEditActionItem[];
|
||||
const crop = edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
if (crop) {
|
||||
if (edits[0].action !== AssetEditAction.Crop) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
|
||||
|
||||
@@ -606,7 +607,7 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const newEdits = await this.assetEditRepository.replaceAll(id, dto.edits);
|
||||
const newEdits = await this.assetEditRepository.replaceAll(id, edits);
|
||||
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
|
||||
|
||||
// Return the asset and its applied edits
|
||||
|
||||
Reference in New Issue
Block a user