feat: asset metadata (#20446)

This commit is contained in:
Jason Rasmussen 2025-08-27 14:31:23 -04:00 committed by GitHub
parent 25a94bd117
commit 88072910da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1099 additions and 18 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.

Binary file not shown.

Binary file not shown.

View File

@ -2245,6 +2245,203 @@
"description": "This endpoint requires the `asset.update` permission."
}
},
"/assets/{id}/metadata": {
"get": {
"operationId": "getAssetMetadata",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetMetadataResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Assets"
],
"x-immich-permission": "asset.read",
"description": "This endpoint requires the `asset.read` permission."
},
"put": {
"operationId": "updateAssetMetadata",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMetadataUpsertDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetMetadataResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Assets"
],
"x-immich-permission": "asset.update",
"description": "This endpoint requires the `asset.update` permission."
}
},
"/assets/{id}/metadata/{key}": {
"delete": {
"operationId": "deleteAssetMetadata",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/AssetMetadataKey"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Assets"
],
"x-immich-permission": "asset.update",
"description": "This endpoint requires the `asset.update` permission."
},
"get": {
"operationId": "getAssetMetadataByKey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/AssetMetadataKey"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMetadataResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Assets"
],
"x-immich-permission": "asset.read",
"description": "This endpoint requires the `asset.read` permission."
}
},
"/assets/{id}/original": {
"get": {
"operationId": "downloadAsset",
@ -10615,6 +10812,12 @@
"format": "uuid",
"type": "string"
},
"metadata": {
"items": {
"$ref": "#/components/schemas/AssetMetadataUpsertItemDto"
},
"type": "array"
},
"sidecarData": {
"format": "binary",
"type": "string"
@ -10632,7 +10835,8 @@
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt"
"fileModifiedAt",
"metadata"
],
"type": "object"
},
@ -10707,6 +10911,69 @@
],
"type": "string"
},
"AssetMetadataKey": {
"enum": [
"mobile-app"
],
"type": "string"
},
"AssetMetadataResponseDto": {
"properties": {
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
},
"updatedAt": {
"format": "date-time",
"type": "string"
},
"value": {
"type": "object"
}
},
"required": [
"key",
"updatedAt",
"value"
],
"type": "object"
},
"AssetMetadataUpsertDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/AssetMetadataUpsertItemDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"AssetMetadataUpsertItemDto": {
"properties": {
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
},
"value": {
"type": "object"
}
},
"required": [
"key",
"value"
],
"type": "object"
},
"AssetOrder": {
"enum": [
"asc",
@ -14944,6 +15211,48 @@
],
"type": "object"
},
"SyncAssetMetadataDeleteV1": {
"properties": {
"assetId": {
"type": "string"
},
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
}
},
"required": [
"assetId",
"key"
],
"type": "object"
},
"SyncAssetMetadataV1": {
"properties": {
"assetId": {
"type": "string"
},
"key": {
"allOf": [
{
"$ref": "#/components/schemas/AssetMetadataKey"
}
]
},
"value": {
"type": "object"
}
},
"required": [
"assetId",
"key",
"value"
],
"type": "object"
},
"SyncAssetV1": {
"properties": {
"checksum": {
@ -15114,6 +15423,8 @@
"AssetV1",
"AssetDeleteV1",
"AssetExifV1",
"AssetMetadataV1",
"AssetMetadataDeleteV1",
"PartnerV1",
"PartnerDeleteV1",
"PartnerAssetV1",
@ -15373,6 +15684,7 @@
"AlbumAssetExifsV1",
"AssetsV1",
"AssetExifsV1",
"AssetMetadataV1",
"AuthUsersV1",
"MemoriesV1",
"MemoryToAssetsV1",

View File

@ -447,6 +447,10 @@ export type AssetBulkDeleteDto = {
force?: boolean;
ids: string[];
};
export type AssetMetadataUpsertItemDto = {
key: AssetMetadataKey;
value: object;
};
export type AssetMediaCreateDto = {
assetData: Blob;
deviceAssetId: string;
@ -457,6 +461,7 @@ export type AssetMediaCreateDto = {
filename?: string;
isFavorite?: boolean;
livePhotoVideoId?: string;
metadata: AssetMetadataUpsertItemDto[];
sidecarData?: Blob;
visibility?: AssetVisibility;
};
@ -516,6 +521,14 @@ export type UpdateAssetDto = {
rating?: number;
visibility?: AssetVisibility;
};
export type AssetMetadataResponseDto = {
key: AssetMetadataKey;
updatedAt: string;
value: object;
};
export type AssetMetadataUpsertDto = {
items: AssetMetadataUpsertItemDto[];
};
export type AssetMediaReplaceDto = {
assetData: Blob;
deviceAssetId: string;
@ -2273,6 +2286,61 @@ export function updateAsset({ id, updateAssetDto }: {
body: updateAssetDto
})));
}
/**
* This endpoint requires the `asset.read` permission.
*/
export function getAssetMetadata({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMetadataResponseDto[];
}>(`/assets/${encodeURIComponent(id)}/metadata`, {
...opts
}));
}
/**
* This endpoint requires the `asset.update` permission.
*/
export function updateAssetMetadata({ id, assetMetadataUpsertDto }: {
id: string;
assetMetadataUpsertDto: AssetMetadataUpsertDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMetadataResponseDto[];
}>(`/assets/${encodeURIComponent(id)}/metadata`, oazapfts.json({
...opts,
method: "PUT",
body: assetMetadataUpsertDto
})));
}
/**
* This endpoint requires the `asset.update` permission.
*/
export function deleteAssetMetadata({ id, key }: {
id: string;
key: AssetMetadataKey;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, {
...opts,
method: "DELETE"
}));
}
/**
* This endpoint requires the `asset.read` permission.
*/
export function getAssetMetadataByKey({ id, key }: {
id: string;
key: AssetMetadataKey;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMetadataResponseDto;
}>(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, {
...opts
}));
}
/**
* This endpoint requires the `asset.download` permission.
*/
@ -4725,6 +4793,9 @@ export enum Permission {
AdminUserDelete = "adminUser.delete",
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
}
export enum AssetMetadataKey {
MobileApp = "mobile-app"
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",
@ -4811,6 +4882,8 @@ export enum SyncEntityType {
AssetV1 = "AssetV1",
AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1",
PartnerAssetV1 = "PartnerAssetV1",
@ -4858,6 +4931,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1",
AssetMetadataV1 = "AssetMetadataV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1",

View File

@ -1,4 +1,5 @@
import { AssetController } from 'src/controllers/asset.controller';
import { AssetMetadataKey } from 'src/enum';
import { AssetService } from 'src/services/asset.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'
describe(AssetController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(AssetService);
beforeAll(async () => {
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]);
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
ctx.reset();
service.resetAllMocks();
});
describe('PUT /assets', () => {
@ -115,4 +118,120 @@ describe(AssetController.name, () => {
);
});
});
describe('GET /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
});
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' }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
),
);
});
it('should require each item to have a value', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
);
});
describe(AssetMetadataKey.MobileApp, () => {
it('should accept valid data and pass to service correctly', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer())
.put(`/assets/${assetId}/metadata`)
.send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] });
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
items: [{ key: 'mobile-app', value: { iCloudId: '123' } }],
});
expect(status).toBe(200);
});
it('should work without iCloudId', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer())
.put(`/assets/${assetId}/metadata`)
.send({ items: [{ key: 'mobile-app', value: {} }] });
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
items: [{ key: 'mobile-app', value: {} }],
});
expect(status).toBe(200);
});
});
});
describe('GET /assets/:id/metadata/:key', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
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', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
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

@ -6,6 +6,9 @@ import {
AssetBulkDeleteDto,
AssetBulkUpdateDto,
AssetJobsDto,
AssetMetadataResponseDto,
AssetMetadataRouteParams,
AssetMetadataUpsertDto,
AssetStatsDto,
AssetStatsResponseDto,
DeviceIdDto,
@ -85,4 +88,36 @@ export class AssetController {
): Promise<AssetResponseDto> {
return this.service.update(auth, id, dto);
}
@Get(':id/metadata')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
return this.service.getMetadata(auth, id);
}
@Put(':id/metadata')
@Authenticated({ permission: Permission.AssetUpdate })
updateAssetMetadata(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetMetadataUpsertDto,
): Promise<AssetMetadataResponseDto[]> {
return this.service.upsertMetadata(auth, id, dto);
}
@Get(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadataByKey(
@Auth() auth: AuthDto,
@Param() { id, key }: AssetMetadataRouteParams,
): Promise<AssetMetadataResponseDto> {
return this.service.getMetadataByKey(auth, id, key);
}
@Delete(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
return this.service.deleteMetadataByKey(auth, id, key);
}
}

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
import { AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
@ -64,6 +65,12 @@ export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;
@Optional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataUpsertItemDto)
metadata!: AssetMetadataUpsertItemDto[];
@ApiProperty({ type: 'string', format: 'binary', required: false })
[UploadFieldName.SIDECAR_DATA]?: any;
}

View File

@ -1,21 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsInt,
IsLatitude,
IsLongitude,
IsNotEmpty,
IsObject,
IsPositive,
IsString,
IsTimeZone,
Max,
Min,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class DeviceIdDto {
@ -135,6 +139,53 @@ export class AssetStatsResponseDto {
total!: number;
}
export class AssetMetadataRouteParams {
@ValidateUUID()
id!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
}
export class AssetMetadataUpsertDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataUpsertItemDto)
items!: AssetMetadataUpsertItemDto[];
}
export class AssetMetadataUpsertItemDto implements AssetMetadataItem {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@IsObject()
@ValidateNested()
@Type((options) => {
switch (options?.object.key) {
case AssetMetadataKey.MobileApp: {
return AssetMetadataMobileAppDto;
}
default: {
return Object;
}
}
})
value!: AssetMetadata[AssetMetadataKey];
}
export class AssetMetadataMobileAppDto {
@IsString()
@Optional()
iCloudId?: string;
}
export class AssetMetadataResponseDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
value!: object;
updatedAt!: Date;
}
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.Image],

View File

@ -4,6 +4,7 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AlbumUserRole,
AssetMetadataKey,
AssetOrder,
AssetType,
AssetVisibility,
@ -162,6 +163,21 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAssetMetadataV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
value!: object;
}
@ExtraModel()
export class SyncAssetMetadataDeleteV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
}
@ExtraModel()
export class SyncAlbumDeleteV1 {
albumId!: string;
@ -328,6 +344,8 @@ export type SyncItem = {
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
[SyncEntityType.AssetV1]: SyncAssetV1;
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;

View File

@ -276,6 +276,10 @@ export enum UserMetadataKey {
Onboarding = 'onboarding',
}
export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
export enum UserAvatarColor {
Primary = 'primary',
Pink = 'pink',
@ -627,6 +631,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1',
AssetMetadataV1 = 'AssetMetadataV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
@ -650,6 +655,8 @@ export enum SyncEntityType {
AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',

View File

@ -19,6 +19,33 @@ returning
"dateTimeOriginal",
"timeZone"
-- AssetRepository.getMetadata
select
"key",
"value",
"updatedAt"
from
"asset_metadata"
where
"assetId" = $1
-- AssetRepository.getMetadataByKey
select
"key",
"value",
"updatedAt"
from
"asset_metadata"
where
"assetId" = $1
and "key" = $2
-- AssetRepository.deleteMetadataByKey
delete from "asset_metadata"
where
"assetId" = $1
and "key" = $2
-- AssetRepository.getByDayOfYear
with
"res" as (

View File

@ -539,6 +539,37 @@ where
order by
"asset_face"."updateId" asc
-- SyncRepository.assetMetadata.getDeletes
select
"asset_metadata_audit"."id",
"assetId",
"key"
from
"asset_metadata_audit" as "asset_metadata_audit"
left join "asset" on "asset"."id" = "asset_metadata_audit"."assetId"
where
"asset_metadata_audit"."id" < $1
and "asset_metadata_audit"."id" > $2
and "asset"."ownerId" = $3
order by
"asset_metadata_audit"."id" asc
-- SyncRepository.assetMetadata.getUpserts
select
"assetId",
"key",
"value",
"asset_metadata"."updateId"
from
"asset_metadata" as "asset_metadata"
inner join "asset" on "asset"."id" = "asset_metadata"."assetId"
where
"asset_metadata"."updateId" < $1
and "asset_metadata"."updateId" > $2
and "asset"."ownerId" = $3
order by
"asset_metadata"."updateId" asc
-- SyncRepository.authUser.getUpserts
select
"id",

View File

@ -1,15 +1,16 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetMetadataKey, 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 { AssetTable } from 'src/schema/tables/asset.table';
import { AssetMetadataItem } from 'src/types';
import {
anyUuid,
asUuid,
@ -210,6 +211,43 @@ export class AssetRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getMetadata(assetId: string) {
return this.db
.selectFrom('asset_metadata')
.select(['key', 'value', 'updatedAt'])
.where('assetId', '=', assetId)
.execute();
}
upsertMetadata(id: string, items: AssetMetadataItem[]) {
return this.db
.insertInto('asset_metadata')
.values(items.map((item) => ({ assetId: id, ...item })))
.onConflict((oc) =>
oc
.columns(['assetId', 'key'])
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
)
.returning(['key', 'value', 'updatedAt'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getMetadataByKey(assetId: string, key: AssetMetadataKey) {
return this.db
.selectFrom('asset_metadata')
.select(['key', 'value', 'updatedAt'])
.where('assetId', '=', assetId)
.where('key', '=', key)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async deleteMetadataByKey(id: string, key: AssetMetadataKey) {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
}
create(asset: Insertable<AssetTable>) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}

View File

@ -54,6 +54,7 @@ export class SyncRepository {
asset: AssetSync;
assetExif: AssetExifSync;
assetFace: AssetFaceSync;
assetMetadata: AssetMetadataSync;
authUser: AuthUserSync;
memory: MemorySync;
memoryToAsset: MemoryToAssetSync;
@ -75,6 +76,7 @@ export class SyncRepository {
this.asset = new AssetSync(this.db);
this.assetExif = new AssetExifSync(this.db);
this.assetFace = new AssetFaceSync(this.db);
this.assetMetadata = new AssetMetadataSync(this.db);
this.authUser = new AuthUserSync(this.db);
this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db);
@ -685,3 +687,23 @@ class UserMetadataSync extends BaseSync {
.stream();
}
}
class AssetMetadataSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getDeletes(options: SyncQueryOptions, userId: string) {
return this.auditQuery('asset_metadata_audit', options)
.select(['asset_metadata_audit.id', 'assetId', 'key'])
.leftJoin('asset', 'asset.id', 'asset_metadata_audit.assetId')
.where('asset.ownerId', '=', userId)
.stream();
}
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getUpserts(options: SyncQueryOptions, userId: string) {
return this.upsertQuery('asset_metadata', options)
.select(['assetId', 'key', 'value', 'asset_metadata.updateId'])
.innerJoin('asset', 'asset.id', 'asset_metadata.assetId')
.where('asset.ownerId', '=', userId)
.stream();
}
}

View File

@ -7,13 +7,10 @@ import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetType, AssetVisibility, UserStatus } from 'src/enum';
import { DB } from 'src/schema';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { UserMetadata, UserMetadataItem } from 'src/types';
import { asUuid } from 'src/utils/database';
type Upsert = Insertable<UserMetadataTable>;
export interface UserListFilter {
id?: string;
withDeleted?: boolean;
@ -211,12 +208,12 @@ export class UserRepository {
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
await this.db
.insertInto('user_metadata')
.values({ userId: id, key, value } as Upsert)
.values({ userId: id, key, value })
.onConflict((oc) =>
oc.columns(['userId', 'key']).doUpdateSet({
key,
value,
} as Upsert),
}),
)
.execute();
}

View File

@ -230,6 +230,19 @@ export const user_metadata_audit = registerFunction({
END`,
});
export const asset_metadata_audit = registerFunction({
name: 'asset_metadata_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO asset_metadata_audit ("assetId", "key")
SELECT "assetId", "key"
FROM OLD;
RETURN NULL;
END`,
});
export const asset_face_audit = registerFunction({
name: 'asset_face_audit',
returnType: 'TRIGGER',

View File

@ -5,6 +5,7 @@ import {
album_user_delete_audit,
asset_delete_audit,
asset_face_audit,
asset_metadata_audit,
f_concat_ws,
f_unaccent,
immich_uuid_v7,
@ -32,6 +33,8 @@ import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@ -81,6 +84,8 @@ export class ImmichDatabase {
AssetAuditTable,
AssetFaceTable,
AssetFaceAuditTable,
AssetMetadataTable,
AssetMetadataAuditTable,
AssetJobStatusTable,
AssetTable,
AssetFileTable,
@ -135,6 +140,7 @@ export class ImmichDatabase {
stack_delete_audit,
person_delete_audit,
user_metadata_audit,
asset_metadata_audit,
asset_face_audit,
];
@ -164,6 +170,8 @@ export interface DB {
asset_face: AssetFaceTable;
asset_face_audit: AssetFaceAuditTable;
asset_file: AssetFileTable;
asset_metadata: AssetMetadataTable;
asset_metadata_audit: AssetMetadataAuditTable;
asset_job_status: AssetJobStatusTable;
asset_audit: AssetAuditTable;

View File

@ -0,0 +1,58 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_metadata_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO asset_metadata_audit ("assetId", "key")
SELECT "assetId", "key"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "asset_metadata_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"assetId" uuid NOT NULL,
"key" character varying NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "asset_metadata_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "asset_metadata_audit_assetId_idx" ON "asset_metadata_audit" ("assetId");`.execute(db);
await sql`CREATE INDEX "asset_metadata_audit_key_idx" ON "asset_metadata_audit" ("key");`.execute(db);
await sql`CREATE INDEX "asset_metadata_audit_deletedAt_idx" ON "asset_metadata_audit" ("deletedAt");`.execute(db);
await sql`CREATE TABLE "asset_metadata" (
"assetId" uuid NOT NULL,
"key" character varying NOT NULL,
"value" jsonb NOT NULL,
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "asset_metadata_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "asset_metadata_pkey" PRIMARY KEY ("assetId", "key")
);`.execute(db);
await sql`CREATE INDEX "asset_metadata_updateId_idx" ON "asset_metadata" ("updateId");`.execute(db);
await sql`CREATE INDEX "asset_metadata_updatedAt_idx" ON "asset_metadata" ("updatedAt");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_metadata_audit"
AFTER DELETE ON "asset_metadata"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION asset_metadata_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_metadata_updated_at"
BEFORE UPDATE ON "asset_metadata"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_metadata_audit', '{"type":"function","name":"asset_metadata_audit","sql":"CREATE OR REPLACE FUNCTION asset_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_metadata_audit (\\"assetId\\", \\"key\\")\\n SELECT \\"assetId\\", \\"key\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_audit', '{"type":"trigger","name":"asset_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_audit\\"\\n AFTER DELETE ON \\"asset_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_metadata_audit();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_updated_at', '{"type":"trigger","name":"asset_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"asset_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "asset_metadata_audit";`.execute(db);
await sql`DROP TABLE "asset_metadata";`.execute(db);
await sql`DROP FUNCTION asset_metadata_audit;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_metadata_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_updated_at';`.execute(db);
}

View File

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

View File

@ -0,0 +1,46 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetMetadataKey } from 'src/enum';
import { asset_metadata_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
AfterDeleteTrigger,
Column,
ForeignKeyColumn,
Generated,
PrimaryColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
@UpdatedAtTrigger('asset_metadata_updated_at')
@Table('asset_metadata')
@AfterDeleteTrigger({
scope: 'statement',
function: asset_metadata_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AssetMetadataTable<T extends keyof AssetMetadata = AssetMetadataKey> implements AssetMetadataItem<T> {
@ForeignKeyColumn(() => AssetTable, {
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
primary: true,
// [assetId, key] is the PK constraint
index: false,
})
assetId!: string;
@PrimaryColumn({ type: 'character varying' })
key!: T;
@Column({ type: 'jsonb' })
value!: AssetMetadata[T];
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@UpdateDateColumn({ index: true })
updatedAt!: Generated<Timestamp>;
}

View File

@ -423,6 +423,10 @@ export class AssetMediaService extends BaseService {
sidecarPath: sidecarFile?.originalPath,
});
if (dto.metadata) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
if (sidecarFile) {
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}

View File

@ -9,12 +9,14 @@ import {
AssetBulkUpdateDto,
AssetJobName,
AssetJobsDto,
AssetMetadataResponseDto,
AssetMetadataUpsertDto,
AssetStatsDto,
UpdateAssetDto,
mapStats,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
@ -93,7 +95,7 @@ export class AssetService extends BaseService {
}
}
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating });
const asset = await this.assetRepository.update({ id, ...rest });
@ -273,6 +275,31 @@ export class AssetService extends BaseService {
});
}
async getMetadata(auth: AuthDto, id: string): Promise<AssetMetadataResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
return this.assetRepository.getMetadata(id);
}
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> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const item = await this.assetRepository.getMetadataByKey(id, key);
if (!item) {
throw new BadRequestException(`Metadata with key "${key}" not found for asset with id "${id}"`);
}
return item;
}
async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.deleteMetadataByKey(id, key);
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });
@ -313,7 +340,7 @@ export class AssetService extends BaseService {
return asset;
}
private async updateMetadata(dto: ISidecarWriteJob) {
private async updateExif(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) {

View File

@ -74,6 +74,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.PeopleV1,
SyncRequestType.AssetFacesV1,
SyncRequestType.UserMetadataV1,
SyncRequestType.AssetMetadataV1,
];
const throwSessionRequired = () => {
@ -156,6 +157,7 @@ export class SyncService extends BaseService {
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
@ -759,6 +761,33 @@ export class SyncService extends BaseService {
}
}
private async syncAssetMetadataV1(
options: SyncQueryOptions,
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
) {
const deleteType = SyncEntityType.AssetMetadataDeleteV1;
const deletes = this.syncRepository.assetMetadata.getDeletes(
{ ...options, ack: checkpointMap[deleteType] },
auth.user.id,
);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetMetadataV1;
const upserts = this.syncRepository.assetMetadata.getUpserts(
{ ...options, ack: checkpointMap[upsertType] },
auth.user.id,
);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncCheckpointRepository.upsertAll([

View File

@ -1,6 +1,7 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import {
AssetMetadataKey,
AssetOrder,
AssetType,
DatabaseSslMode,
@ -465,11 +466,6 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.MemoriesState]: MemoriesState;
}
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;
value: UserMetadata[T];
};
export interface UserPreferences {
albums: {
defaultAssetOrder: AssetOrder;
@ -514,8 +510,22 @@ export interface UserPreferences {
};
}
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;
value: UserMetadata[T];
};
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
[UserMetadataKey.Preferences]: DeepPartial<UserPreferences>;
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
}
export type AssetMetadataItem<T extends keyof AssetMetadata = AssetMetadataKey> = {
key: T;
value: AssetMetadata[T];
};
export interface AssetMetadata extends Record<AssetMetadataKey, Record<string, any>> {
[AssetMetadataKey.MobileApp]: { iCloudId: string };
}

View File

@ -0,0 +1,126 @@
import { Kysely } from 'kysely';
import { AssetMetadataKey, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.AssetMetadataV1, () => {
it('should detect and sync new asset metadata', async () => {
const { auth, user, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc123' },
},
type: 'AssetMetadataV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([]);
});
it('should update asset metadata', async () => {
const { auth, user, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc123' },
},
type: 'AssetMetadataV1',
},
]);
await ctx.syncAckAll(auth, response);
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc456' } }]);
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(updatedResponse).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc456' },
},
type: 'AssetMetadataV1',
},
]);
await ctx.syncAckAll(auth, updatedResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([]);
});
});
describe(SyncEntityType.AssetMetadataDeleteV1, () => {
it('should delete and sync asset metadata', async () => {
const { auth, user, ctx } = await setup();
const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
key: AssetMetadataKey.MobileApp,
assetId: asset.id,
value: { iCloudId: 'abc123' },
},
type: 'AssetMetadataV1',
},
]);
await ctx.syncAckAll(auth, response);
await assetRepo.deleteMetadataByKey(asset.id, AssetMetadataKey.MobileApp);
await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
key: AssetMetadataKey.MobileApp,
},
type: 'AssetMetadataDeleteV1',
},
]);
});
});

View File

@ -41,5 +41,9 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
filterNewExternalAssetPaths: vitest.fn(),
updateByLibraryId: vitest.fn(),
getFileSamples: vitest.fn(),
getMetadata: vitest.fn(),
upsertMetadata: vitest.fn(),
getMetadataByKey: vitest.fn(),
deleteMetadataByKey: vitest.fn(),
};
};