feat: bulk asset metadata endpoints (#25133)

This commit is contained in:
Jason Rasmussen 2026-01-08 14:52:16 -05:00 committed by GitHub
parent 109c79125d
commit a2ba36c16d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 635 additions and 93 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2906,6 +2906,112 @@
"x-immich-state": "Stable"
}
},
"/assets/metadata": {
"delete": {
"description": "Delete metadata key-value pairs for multiple assets.",
"operationId": "deleteBulkAssetMetadata",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMetadataBulkDeleteDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete asset metadata",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2.5.0",
"state": "Beta"
}
],
"x-immich-permission": "asset.update",
"x-immich-state": "Beta"
},
"put": {
"description": "Upsert metadata key-value pairs for multiple assets.",
"operationId": "updateBulkAssetMetadata",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMetadataBulkUpsertDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetMetadataBulkResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Upsert asset metadata",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2.5.0",
"state": "Beta"
}
],
"x-immich-permission": "asset.update",
"x-immich-state": "Beta"
}
},
"/assets/random": {
"get": {
"deprecated": true,
@ -3340,7 +3446,7 @@
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/AssetMetadataKey"
"type": "string"
}
}
],
@ -3399,7 +3505,7 @@
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/AssetMetadataKey"
"type": "string"
}
}
],
@ -15575,20 +15681,98 @@
],
"type": "string"
},
"AssetMetadataKey": {
"enum": [
"mobile-app"
"AssetMetadataBulkDeleteDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/AssetMetadataBulkDeleteItemDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "string"
"type": "object"
},
"AssetMetadataBulkDeleteItemDto": {
"properties": {
"assetId": {
"format": "uuid",
"type": "string"
},
"key": {
"type": "string"
}
},
"required": [
"assetId",
"key"
],
"type": "object"
},
"AssetMetadataBulkResponseDto": {
"properties": {
"assetId": {
"type": "string"
},
"key": {
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
},
"value": {
"type": "object"
}
},
"required": [
"assetId",
"key",
"updatedAt",
"value"
],
"type": "object"
},
"AssetMetadataBulkUpsertDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/AssetMetadataBulkUpsertItemDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"AssetMetadataBulkUpsertItemDto": {
"properties": {
"assetId": {
"format": "uuid",
"type": "string"
},
"key": {
"type": "string"
},
"value": {
"type": "object"
}
},
"required": [
"assetId",
"key",
"value"
],
"type": "object"
},
"AssetMetadataResponseDto": {
"properties": {
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
"type": "string"
},
"updatedAt": {
"format": "date-time",
@ -15622,11 +15806,7 @@
"AssetMetadataUpsertItemDto": {
"properties": {
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
"type": "string"
},
"value": {
"type": "object"
@ -20651,11 +20831,7 @@
"type": "string"
},
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
"type": "string"
}
},
"required": [
@ -20670,11 +20846,7 @@
"type": "string"
},
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
"type": "string"
},
"value": {
"type": "object"

View file

@ -471,7 +471,7 @@ export type AssetBulkDeleteDto = {
ids: string[];
};
export type AssetMetadataUpsertItemDto = {
key: AssetMetadataKey;
key: string;
value: object;
};
export type AssetMediaCreateDto = {
@ -543,6 +543,27 @@ export type AssetJobsDto = {
assetIds: string[];
name: AssetJobName;
};
export type AssetMetadataBulkDeleteItemDto = {
assetId: string;
key: string;
};
export type AssetMetadataBulkDeleteDto = {
items: AssetMetadataBulkDeleteItemDto[];
};
export type AssetMetadataBulkUpsertItemDto = {
assetId: string;
key: string;
value: object;
};
export type AssetMetadataBulkUpsertDto = {
items: AssetMetadataBulkUpsertItemDto[];
};
export type AssetMetadataBulkResponseDto = {
assetId: string;
key: string;
updatedAt: string;
value: object;
};
export type UpdateAssetDto = {
dateTimeOriginal?: string;
description?: string;
@ -554,7 +575,7 @@ export type UpdateAssetDto = {
visibility?: AssetVisibility;
};
export type AssetMetadataResponseDto = {
key: AssetMetadataKey;
key: string;
updatedAt: string;
value: object;
};
@ -2462,6 +2483,33 @@ export function runAssetJobs({ assetJobsDto }: {
body: assetJobsDto
})));
}
/**
* Delete asset metadata
*/
export function deleteBulkAssetMetadata({ assetMetadataBulkDeleteDto }: {
assetMetadataBulkDeleteDto: AssetMetadataBulkDeleteDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/assets/metadata", oazapfts.json({
...opts,
method: "DELETE",
body: assetMetadataBulkDeleteDto
})));
}
/**
* Upsert asset metadata
*/
export function updateBulkAssetMetadata({ assetMetadataBulkUpsertDto }: {
assetMetadataBulkUpsertDto: AssetMetadataBulkUpsertDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMetadataBulkResponseDto[];
}>("/assets/metadata", oazapfts.json({
...opts,
method: "PUT",
body: assetMetadataBulkUpsertDto
})));
}
/**
* Get random assets
*/
@ -2564,7 +2612,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: {
*/
export function deleteAssetMetadata({ id, key }: {
id: string;
key: AssetMetadataKey;
key: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, {
...opts,
@ -2576,7 +2624,7 @@ export function deleteAssetMetadata({ id, key }: {
*/
export function getAssetMetadataByKey({ id, key }: {
id: string;
key: AssetMetadataKey;
key: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -5363,9 +5411,6 @@ export enum Permission {
AdminSessionRead = "adminSession.read",
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
}
export enum AssetMetadataKey {
MobileApp = "mobile-app"
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",

View file

@ -79,6 +79,74 @@ describe(AssetController.name, () => {
});
});
describe('PUT /assets/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/assets/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid assetId', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
});
it('should require a key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put('/assets/metadata')
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
),
);
});
it('should work', async () => {
const { status } = await request(ctx.getHttpServer())
.put('/assets/metadata')
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] });
expect(status).toBe(200);
});
});
describe('DELETE /assets/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/assets/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid assetId', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
});
it('should require a key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/assets/metadata')
.send({ items: [{ assetId: factory.uuid() }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
),
);
});
it('should work', async () => {
const { status } = await request(ctx.getHttpServer())
.delete('/assets/metadata')
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] });
expect(status).toBe(204);
});
});
describe('PUT /assets/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/123`);
@ -169,12 +237,10 @@ describe(AssetController.name, () => {
it('should require each item to have a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'someKey' }] });
.send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
),
factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']),
);
});
@ -224,16 +290,6 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
),
);
});
});
describe('DELETE /assets/:id/metadata/:key', () => {
@ -247,13 +303,5 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
);
});
});
});

View file

@ -7,6 +7,9 @@ import {
AssetBulkUpdateDto,
AssetCopyDto,
AssetJobsDto,
AssetMetadataBulkDeleteDto,
AssetMetadataBulkResponseDto,
AssetMetadataBulkUpsertDto,
AssetMetadataResponseDto,
AssetMetadataRouteParams,
AssetMetadataUpsertDto,
@ -120,6 +123,32 @@ export class AssetController {
return this.service.copy(auth, dto);
}
@Put('metadata')
@Authenticated({ permission: Permission.AssetUpdate })
@Endpoint({
summary: 'Upsert asset metadata',
description: 'Upsert metadata key-value pairs for multiple assets.',
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
})
updateBulkAssetMetadata(
@Auth() auth: AuthDto,
@Body() dto: AssetMetadataBulkUpsertDto,
): Promise<AssetMetadataBulkResponseDto[]> {
return this.service.upsertBulkMetadata(auth, dto);
}
@Delete('metadata')
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete asset metadata',
description: 'Delete metadata key-value pairs for multiple assets.',
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
})
deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise<void> {
return this.service.deleteBulkMetadata(auth, dto);
}
@Put(':id')
@Authenticated({ permission: Permission.AssetUpdate })
@Endpoint({

View file

@ -17,9 +17,9 @@ import {
ValidateNested,
} from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class DeviceIdDto {
@IsNotEmpty()
@ -142,8 +142,8 @@ export class AssetMetadataRouteParams {
@ValidateUUID()
id!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@ValidateString()
key!: string;
}
export class AssetMetadataUpsertDto {
@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto {
}
export class AssetMetadataUpsertItemDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@ValidateString()
key!: string;
@IsObject()
value!: object;
}
export class AssetMetadataMobileAppDto {
@IsString()
@Optional()
iCloudId?: string;
export class AssetMetadataBulkUpsertDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataBulkUpsertItemDto)
items!: AssetMetadataBulkUpsertItemDto[];
}
export class AssetMetadataBulkUpsertItemDto {
@ValidateUUID()
assetId!: string;
@ValidateString()
key!: string;
@IsObject()
value!: object;
}
export class AssetMetadataBulkDeleteDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataBulkDeleteItemDto)
items!: AssetMetadataBulkDeleteItemDto[];
}
export class AssetMetadataBulkDeleteItemDto {
@ValidateUUID()
assetId!: string;
@ValidateString()
key!: string;
}
export class AssetMetadataResponseDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@ValidateString()
key!: string;
value!: object;
updatedAt!: Date;
}
export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto {
assetId!: string;
}
export class AssetCopyDto {
@ValidateUUID()
sourceId!: string;

View file

@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AlbumUserRole,
AssetMetadataKey,
AssetOrder,
AssetType,
AssetVisibility,
@ -167,16 +166,14 @@ export class SyncAssetExifV1 {
@ExtraModel()
export class SyncAssetMetadataV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
key!: string;
value!: object;
}
@ExtraModel()
export class SyncAssetMetadataDeleteV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
key!: string;
}
@ExtraModel()

View file

@ -76,6 +76,14 @@ where
"assetId" = $1
and "key" = $2
-- AssetRepository.deleteBulkMetadata
begin
delete from "asset_metadata"
where
"assetId" = $1
and "key" = $2
commit
-- AssetRepository.getByDayOfYear
with
"res" as (

View file

@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
anyUuid,
@ -256,7 +257,7 @@ export class AssetRepository {
.execute();
}
upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) {
upsertMetadata(id: string, items: Array<{ key: string; value: object }>) {
return this.db
.insertInto('asset_metadata')
.values(items.map((item) => ({ assetId: id, ...item })))
@ -269,8 +270,21 @@ export class AssetRepository {
.execute();
}
upsertBulkMetadata(items: Insertable<AssetMetadataTable>[]) {
return this.db
.insertInto('asset_metadata')
.values(items)
.onConflict((oc) =>
oc
.columns(['assetId', 'key'])
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
)
.returning(['assetId', 'key', 'value', 'updatedAt'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getMetadataByKey(assetId: string, key: AssetMetadataKey) {
getMetadataByKey(assetId: string, key: string) {
return this.db
.selectFrom('asset_metadata')
.select(['key', 'value', 'updatedAt'])
@ -280,10 +294,23 @@ export class AssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async deleteMetadataByKey(id: string, key: AssetMetadataKey) {
async deleteMetadataByKey(id: string, key: string) {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] })
async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) {
if (items.length === 0) {
return;
}
await this.db.transaction().execute(async (tx) => {
for (const { assetId, key } of items) {
await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute();
}
});
}
create(asset: Insertable<AssetTable>) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}

View file

@ -1,5 +1,4 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { AssetMetadataKey } from 'src/enum';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('asset_metadata_audit')
@ -11,7 +10,7 @@ export class AssetMetadataAuditTable {
assetId!: string;
@Column({ index: true })
key!: AssetMetadataKey;
key!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;

View file

@ -32,7 +32,7 @@ export class AssetMetadataTable {
assetId!: string;
@PrimaryColumn({ type: 'character varying' })
key!: AssetMetadataKey;
key!: AssetMetadataKey | string;
@Column({ type: 'jsonb' })
value!: object;

View file

@ -11,6 +11,9 @@ import {
AssetCopyDto,
AssetJobName,
AssetJobsDto,
AssetMetadataBulkDeleteDto,
AssetMetadataBulkResponseDto,
AssetMetadataBulkUpsertDto,
AssetMetadataResponseDto,
AssetMetadataUpsertDto,
AssetStatsDto,
@ -19,16 +22,7 @@ import {
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import {
AssetFileType,
AssetMetadataKey,
AssetStatus,
AssetVisibility,
JobName,
JobStatus,
Permission,
QueueName,
} from 'src/enum';
import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
@ -381,12 +375,17 @@ export class AssetService extends BaseService {
return this.ocrRepository.getByAssetId(id);
}
async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise<AssetMetadataBulkResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
return this.assetRepository.upsertBulkMetadata(dto.items);
}
async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise<AssetMetadataResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.upsertMetadata(id, dto.items);
}
async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<AssetMetadataResponseDto> {
async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise<AssetMetadataResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const item = await this.assetRepository.getMetadataByKey(id, key);
@ -396,11 +395,16 @@ export class AssetService extends BaseService {
return item;
}
async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<void> {
async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.deleteMetadataByKey(id, key);
}
async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
await this.assetRepository.deleteBulkMetadata(dto.items);
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });

View file

@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
@ -179,6 +180,12 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { asset, result };
}
async newMetadata(dto: Insertable<AssetMetadataTable>) {
const { assetId, ...item } = dto;
const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]);
return { metadata: dto, result };
}
async newAssetFile(dto: Insertable<AssetFileTable>) {
const result = await this.get(AssetRepository).upsertFile(dto);
return { result };

View file

@ -1,5 +1,5 @@
import { Kysely } from 'kysely';
import { AssetFileType, JobName, SharedLinkType } from 'src/enum';
import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
@ -430,4 +430,177 @@ describe(AssetService.name, () => {
);
});
});
describe('upsertBulkMetadata', () => {
it('should work', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }];
await sut.upsertBulkMetadata(auth, { items });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata.length).toEqual(1);
expect(metadata[0]).toEqual(
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }),
);
});
it('should work on conflict', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } });
// verify existing metadata
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }),
]);
const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }];
await sut.upsertBulkMetadata(auth, { items });
// verify updated metadata
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }),
]);
});
it('should work with multiple assets', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const items = [
{ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } },
];
await sut.upsertBulkMetadata(auth, { items });
const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id);
expect(metadata1).toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }),
]);
const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id);
expect(metadata2).toEqual([
expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }),
]);
});
it('should work with multiple metadata for the same asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
const items = [
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } },
];
await sut.upsertBulkMetadata(auth, { items });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: AssetMetadataKey.MobileApp,
value: { iCloudId: 'id1' },
}),
expect.objectContaining({
key: 'some-other-key',
value: { foo: 'bar' },
}),
]),
);
});
});
describe('deleteBulkMetadata', () => {
it('should work', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } });
await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata.length).toEqual(0);
});
it('should work even if the item does not exist', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] });
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata.length).toEqual(0);
});
it('should work with multiple assets', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } });
await sut.deleteBulkMetadata(auth, {
items: [
{ assetId: asset1.id, key: AssetMetadataKey.MobileApp },
{ assetId: asset2.id, key: AssetMetadataKey.MobileApp },
],
});
await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]);
await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]);
});
it('should work with multiple metadata for the same asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } });
await sut.deleteBulkMetadata(auth, {
items: [
{ assetId: asset.id, key: AssetMetadataKey.MobileApp },
{ assetId: asset.id, key: 'some-other-key' },
],
});
await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]);
});
it('should not delete unspecified keys', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } });
await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } });
await sut.deleteBulkMetadata(auth, {
items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }],
});
const metadata = await ctx.get(AssetRepository).getMetadata(asset.id);
expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]);
});
});
});

View file

@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
updateByLibraryId: vitest.fn(),
getFileSamples: vitest.fn(),
getMetadata: vitest.fn(),
upsertMetadata: vitest.fn(),
getMetadataByKey: vitest.fn(),
upsertMetadata: vitest.fn(),
upsertBulkMetadata: vitest.fn(),
deleteMetadataByKey: vitest.fn(),
deleteBulkMetadata: vitest.fn(),
};
};