mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat: asset metadata (#20446)
This commit is contained in:
parent
25a94bd117
commit
88072910da
37 changed files with 1099 additions and 18 deletions
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/assets_api.dart
generated
BIN
mobile/openapi/lib/api/assets_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_helper.dart
generated
BIN
mobile/openapi/lib/api_helper.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_metadata_key.dart
generated
Normal file
BIN
mobile/openapi/lib/model/asset_metadata_key.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_metadata_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/asset_metadata_response_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_metadata_upsert_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/asset_metadata_upsert_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart
generated
Normal file
BIN
mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_asset_metadata_v1.dart
generated
Normal file
BIN
mobile/openapi/lib/model/sync_asset_metadata_v1.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_entity_type.dart
generated
BIN
mobile/openapi/lib/model/sync_entity_type.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_request_type.dart
generated
BIN
mobile/openapi/lib/model/sync_request_type.dart
generated
Binary file not shown.
|
|
@ -2245,6 +2245,203 @@
|
||||||
"description": "This endpoint requires the `asset.update` permission."
|
"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": {
|
"/assets/{id}/original": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "downloadAsset",
|
"operationId": "downloadAsset",
|
||||||
|
|
@ -10615,6 +10812,12 @@
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"metadata": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetMetadataUpsertItemDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sidecarData": {
|
"sidecarData": {
|
||||||
"format": "binary",
|
"format": "binary",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -10632,7 +10835,8 @@
|
||||||
"deviceAssetId",
|
"deviceAssetId",
|
||||||
"deviceId",
|
"deviceId",
|
||||||
"fileCreatedAt",
|
"fileCreatedAt",
|
||||||
"fileModifiedAt"
|
"fileModifiedAt",
|
||||||
|
"metadata"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
|
@ -10707,6 +10911,69 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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": {
|
"AssetOrder": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"asc",
|
"asc",
|
||||||
|
|
@ -14944,6 +15211,48 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"SyncAssetV1": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"checksum": {
|
"checksum": {
|
||||||
|
|
@ -15114,6 +15423,8 @@
|
||||||
"AssetV1",
|
"AssetV1",
|
||||||
"AssetDeleteV1",
|
"AssetDeleteV1",
|
||||||
"AssetExifV1",
|
"AssetExifV1",
|
||||||
|
"AssetMetadataV1",
|
||||||
|
"AssetMetadataDeleteV1",
|
||||||
"PartnerV1",
|
"PartnerV1",
|
||||||
"PartnerDeleteV1",
|
"PartnerDeleteV1",
|
||||||
"PartnerAssetV1",
|
"PartnerAssetV1",
|
||||||
|
|
@ -15373,6 +15684,7 @@
|
||||||
"AlbumAssetExifsV1",
|
"AlbumAssetExifsV1",
|
||||||
"AssetsV1",
|
"AssetsV1",
|
||||||
"AssetExifsV1",
|
"AssetExifsV1",
|
||||||
|
"AssetMetadataV1",
|
||||||
"AuthUsersV1",
|
"AuthUsersV1",
|
||||||
"MemoriesV1",
|
"MemoriesV1",
|
||||||
"MemoryToAssetsV1",
|
"MemoryToAssetsV1",
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,10 @@ export type AssetBulkDeleteDto = {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
};
|
};
|
||||||
|
export type AssetMetadataUpsertItemDto = {
|
||||||
|
key: AssetMetadataKey;
|
||||||
|
value: object;
|
||||||
|
};
|
||||||
export type AssetMediaCreateDto = {
|
export type AssetMediaCreateDto = {
|
||||||
assetData: Blob;
|
assetData: Blob;
|
||||||
deviceAssetId: string;
|
deviceAssetId: string;
|
||||||
|
|
@ -457,6 +461,7 @@ export type AssetMediaCreateDto = {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
livePhotoVideoId?: string;
|
livePhotoVideoId?: string;
|
||||||
|
metadata: AssetMetadataUpsertItemDto[];
|
||||||
sidecarData?: Blob;
|
sidecarData?: Blob;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
|
|
@ -516,6 +521,14 @@ export type UpdateAssetDto = {
|
||||||
rating?: number;
|
rating?: number;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
|
export type AssetMetadataResponseDto = {
|
||||||
|
key: AssetMetadataKey;
|
||||||
|
updatedAt: string;
|
||||||
|
value: object;
|
||||||
|
};
|
||||||
|
export type AssetMetadataUpsertDto = {
|
||||||
|
items: AssetMetadataUpsertItemDto[];
|
||||||
|
};
|
||||||
export type AssetMediaReplaceDto = {
|
export type AssetMediaReplaceDto = {
|
||||||
assetData: Blob;
|
assetData: Blob;
|
||||||
deviceAssetId: string;
|
deviceAssetId: string;
|
||||||
|
|
@ -2273,6 +2286,61 @@ export function updateAsset({ id, updateAssetDto }: {
|
||||||
body: 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.
|
* This endpoint requires the `asset.download` permission.
|
||||||
*/
|
*/
|
||||||
|
|
@ -4725,6 +4793,9 @@ export enum Permission {
|
||||||
AdminUserDelete = "adminUser.delete",
|
AdminUserDelete = "adminUser.delete",
|
||||||
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
|
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
|
||||||
}
|
}
|
||||||
|
export enum AssetMetadataKey {
|
||||||
|
MobileApp = "mobile-app"
|
||||||
|
}
|
||||||
export enum AssetMediaStatus {
|
export enum AssetMediaStatus {
|
||||||
Created = "created",
|
Created = "created",
|
||||||
Replaced = "replaced",
|
Replaced = "replaced",
|
||||||
|
|
@ -4811,6 +4882,8 @@ export enum SyncEntityType {
|
||||||
AssetV1 = "AssetV1",
|
AssetV1 = "AssetV1",
|
||||||
AssetDeleteV1 = "AssetDeleteV1",
|
AssetDeleteV1 = "AssetDeleteV1",
|
||||||
AssetExifV1 = "AssetExifV1",
|
AssetExifV1 = "AssetExifV1",
|
||||||
|
AssetMetadataV1 = "AssetMetadataV1",
|
||||||
|
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
||||||
PartnerV1 = "PartnerV1",
|
PartnerV1 = "PartnerV1",
|
||||||
PartnerDeleteV1 = "PartnerDeleteV1",
|
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||||
PartnerAssetV1 = "PartnerAssetV1",
|
PartnerAssetV1 = "PartnerAssetV1",
|
||||||
|
|
@ -4858,6 +4931,7 @@ export enum SyncRequestType {
|
||||||
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
||||||
AssetsV1 = "AssetsV1",
|
AssetsV1 = "AssetsV1",
|
||||||
AssetExifsV1 = "AssetExifsV1",
|
AssetExifsV1 = "AssetExifsV1",
|
||||||
|
AssetMetadataV1 = "AssetMetadataV1",
|
||||||
AuthUsersV1 = "AuthUsersV1",
|
AuthUsersV1 = "AuthUsersV1",
|
||||||
MemoriesV1 = "MemoriesV1",
|
MemoriesV1 = "MemoriesV1",
|
||||||
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { AssetController } from 'src/controllers/asset.controller';
|
import { AssetController } from 'src/controllers/asset.controller';
|
||||||
|
import { AssetMetadataKey } from 'src/enum';
|
||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
|
|
@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'
|
||||||
|
|
||||||
describe(AssetController.name, () => {
|
describe(AssetController.name, () => {
|
||||||
let ctx: ControllerContext;
|
let ctx: ControllerContext;
|
||||||
|
const service = mockBaseService(AssetService);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]);
|
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
|
||||||
return () => ctx.close();
|
return () => ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ctx.reset();
|
ctx.reset();
|
||||||
|
service.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PUT /assets', () => {
|
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')]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import {
|
||||||
AssetBulkDeleteDto,
|
AssetBulkDeleteDto,
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
AssetJobsDto,
|
AssetJobsDto,
|
||||||
|
AssetMetadataResponseDto,
|
||||||
|
AssetMetadataRouteParams,
|
||||||
|
AssetMetadataUpsertDto,
|
||||||
AssetStatsDto,
|
AssetStatsDto,
|
||||||
AssetStatsResponseDto,
|
AssetStatsResponseDto,
|
||||||
DeviceIdDto,
|
DeviceIdDto,
|
||||||
|
|
@ -85,4 +88,36 @@ export class AssetController {
|
||||||
): Promise<AssetResponseDto> {
|
): Promise<AssetResponseDto> {
|
||||||
return this.service.update(auth, id, dto);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
|
||||||
import { AssetVisibility } from 'src/enum';
|
import { AssetVisibility } from 'src/enum';
|
||||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
|
|
@ -64,6 +65,12 @@ export class AssetMediaCreateDto extends AssetMediaBase {
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true })
|
||||||
livePhotoVideoId?: string;
|
livePhotoVideoId?: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AssetMetadataUpsertItemDto)
|
||||||
|
metadata!: AssetMetadataUpsertItemDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsLatitude,
|
IsLatitude,
|
||||||
IsLongitude,
|
IsLongitude,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
IsTimeZone,
|
IsTimeZone,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
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 { AssetStats } from 'src/repositories/asset.repository';
|
||||||
|
import { AssetMetadata, AssetMetadataItem } from 'src/types';
|
||||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class DeviceIdDto {
|
export class DeviceIdDto {
|
||||||
|
|
@ -135,6 +139,53 @@ export class AssetStatsResponseDto {
|
||||||
total!: number;
|
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 => {
|
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||||
return {
|
return {
|
||||||
images: stats[AssetType.Image],
|
images: stats[AssetType.Image],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
|
AssetMetadataKey,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
AssetType,
|
AssetType,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
|
|
@ -162,6 +163,21 @@ export class SyncAssetExifV1 {
|
||||||
fps!: number | null;
|
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()
|
@ExtraModel()
|
||||||
export class SyncAlbumDeleteV1 {
|
export class SyncAlbumDeleteV1 {
|
||||||
albumId!: string;
|
albumId!: string;
|
||||||
|
|
@ -328,6 +344,8 @@ export type SyncItem = {
|
||||||
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
||||||
[SyncEntityType.AssetV1]: SyncAssetV1;
|
[SyncEntityType.AssetV1]: SyncAssetV1;
|
||||||
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
||||||
|
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||||
|
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||||
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,10 @@ export enum UserMetadataKey {
|
||||||
Onboarding = 'onboarding',
|
Onboarding = 'onboarding',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AssetMetadataKey {
|
||||||
|
MobileApp = 'mobile-app',
|
||||||
|
}
|
||||||
|
|
||||||
export enum UserAvatarColor {
|
export enum UserAvatarColor {
|
||||||
Primary = 'primary',
|
Primary = 'primary',
|
||||||
Pink = 'pink',
|
Pink = 'pink',
|
||||||
|
|
@ -627,6 +631,7 @@ export enum SyncRequestType {
|
||||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||||
AssetsV1 = 'AssetsV1',
|
AssetsV1 = 'AssetsV1',
|
||||||
AssetExifsV1 = 'AssetExifsV1',
|
AssetExifsV1 = 'AssetExifsV1',
|
||||||
|
AssetMetadataV1 = 'AssetMetadataV1',
|
||||||
AuthUsersV1 = 'AuthUsersV1',
|
AuthUsersV1 = 'AuthUsersV1',
|
||||||
MemoriesV1 = 'MemoriesV1',
|
MemoriesV1 = 'MemoriesV1',
|
||||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||||
|
|
@ -650,6 +655,8 @@ export enum SyncEntityType {
|
||||||
AssetV1 = 'AssetV1',
|
AssetV1 = 'AssetV1',
|
||||||
AssetDeleteV1 = 'AssetDeleteV1',
|
AssetDeleteV1 = 'AssetDeleteV1',
|
||||||
AssetExifV1 = 'AssetExifV1',
|
AssetExifV1 = 'AssetExifV1',
|
||||||
|
AssetMetadataV1 = 'AssetMetadataV1',
|
||||||
|
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||||
|
|
||||||
PartnerV1 = 'PartnerV1',
|
PartnerV1 = 'PartnerV1',
|
||||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,33 @@ returning
|
||||||
"dateTimeOriginal",
|
"dateTimeOriginal",
|
||||||
"timeZone"
|
"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
|
-- AssetRepository.getByDayOfYear
|
||||||
with
|
with
|
||||||
"res" as (
|
"res" as (
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,37 @@ where
|
||||||
order by
|
order by
|
||||||
"asset_face"."updateId" asc
|
"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
|
-- SyncRepository.authUser.getUpserts
|
||||||
select
|
select
|
||||||
"id",
|
"id",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
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 { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Stack } from 'src/database';
|
import { Stack } from 'src/database';
|
||||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
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 { DB } from 'src/schema';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
|
import { AssetMetadataItem } from 'src/types';
|
||||||
import {
|
import {
|
||||||
anyUuid,
|
anyUuid,
|
||||||
asUuid,
|
asUuid,
|
||||||
|
|
@ -210,6 +211,43 @@ export class AssetRepository {
|
||||||
.execute();
|
.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>) {
|
create(asset: Insertable<AssetTable>) {
|
||||||
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export class SyncRepository {
|
||||||
asset: AssetSync;
|
asset: AssetSync;
|
||||||
assetExif: AssetExifSync;
|
assetExif: AssetExifSync;
|
||||||
assetFace: AssetFaceSync;
|
assetFace: AssetFaceSync;
|
||||||
|
assetMetadata: AssetMetadataSync;
|
||||||
authUser: AuthUserSync;
|
authUser: AuthUserSync;
|
||||||
memory: MemorySync;
|
memory: MemorySync;
|
||||||
memoryToAsset: MemoryToAssetSync;
|
memoryToAsset: MemoryToAssetSync;
|
||||||
|
|
@ -75,6 +76,7 @@ export class SyncRepository {
|
||||||
this.asset = new AssetSync(this.db);
|
this.asset = new AssetSync(this.db);
|
||||||
this.assetExif = new AssetExifSync(this.db);
|
this.assetExif = new AssetExifSync(this.db);
|
||||||
this.assetFace = new AssetFaceSync(this.db);
|
this.assetFace = new AssetFaceSync(this.db);
|
||||||
|
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||||
this.authUser = new AuthUserSync(this.db);
|
this.authUser = new AuthUserSync(this.db);
|
||||||
this.memory = new MemorySync(this.db);
|
this.memory = new MemorySync(this.db);
|
||||||
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
||||||
|
|
@ -685,3 +687,23 @@ class UserMetadataSync extends BaseSync {
|
||||||
.stream();
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,10 @@ import { columns } from 'src/database';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetType, AssetVisibility, UserStatus } from 'src/enum';
|
import { AssetType, AssetVisibility, UserStatus } from 'src/enum';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { UserMetadata, UserMetadataItem } from 'src/types';
|
import { UserMetadata, UserMetadataItem } from 'src/types';
|
||||||
import { asUuid } from 'src/utils/database';
|
import { asUuid } from 'src/utils/database';
|
||||||
|
|
||||||
type Upsert = Insertable<UserMetadataTable>;
|
|
||||||
|
|
||||||
export interface UserListFilter {
|
export interface UserListFilter {
|
||||||
id?: string;
|
id?: string;
|
||||||
withDeleted?: boolean;
|
withDeleted?: boolean;
|
||||||
|
|
@ -211,12 +208,12 @@ export class UserRepository {
|
||||||
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
|
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
|
||||||
await this.db
|
await this.db
|
||||||
.insertInto('user_metadata')
|
.insertInto('user_metadata')
|
||||||
.values({ userId: id, key, value } as Upsert)
|
.values({ userId: id, key, value })
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc.columns(['userId', 'key']).doUpdateSet({
|
oc.columns(['userId', 'key']).doUpdateSet({
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
} as Upsert),
|
}),
|
||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,19 @@ export const user_metadata_audit = registerFunction({
|
||||||
END`,
|
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({
|
export const asset_face_audit = registerFunction({
|
||||||
name: 'asset_face_audit',
|
name: 'asset_face_audit',
|
||||||
returnType: 'TRIGGER',
|
returnType: 'TRIGGER',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
album_user_delete_audit,
|
album_user_delete_audit,
|
||||||
asset_delete_audit,
|
asset_delete_audit,
|
||||||
asset_face_audit,
|
asset_face_audit,
|
||||||
|
asset_metadata_audit,
|
||||||
f_concat_ws,
|
f_concat_ws,
|
||||||
f_unaccent,
|
f_unaccent,
|
||||||
immich_uuid_v7,
|
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 { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.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 { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { AuditTable } from 'src/schema/tables/audit.table';
|
import { AuditTable } from 'src/schema/tables/audit.table';
|
||||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
|
|
@ -81,6 +84,8 @@ export class ImmichDatabase {
|
||||||
AssetAuditTable,
|
AssetAuditTable,
|
||||||
AssetFaceTable,
|
AssetFaceTable,
|
||||||
AssetFaceAuditTable,
|
AssetFaceAuditTable,
|
||||||
|
AssetMetadataTable,
|
||||||
|
AssetMetadataAuditTable,
|
||||||
AssetJobStatusTable,
|
AssetJobStatusTable,
|
||||||
AssetTable,
|
AssetTable,
|
||||||
AssetFileTable,
|
AssetFileTable,
|
||||||
|
|
@ -135,6 +140,7 @@ export class ImmichDatabase {
|
||||||
stack_delete_audit,
|
stack_delete_audit,
|
||||||
person_delete_audit,
|
person_delete_audit,
|
||||||
user_metadata_audit,
|
user_metadata_audit,
|
||||||
|
asset_metadata_audit,
|
||||||
asset_face_audit,
|
asset_face_audit,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -164,6 +170,8 @@ export interface DB {
|
||||||
asset_face: AssetFaceTable;
|
asset_face: AssetFaceTable;
|
||||||
asset_face_audit: AssetFaceAuditTable;
|
asset_face_audit: AssetFaceAuditTable;
|
||||||
asset_file: AssetFileTable;
|
asset_file: AssetFileTable;
|
||||||
|
asset_metadata: AssetMetadataTable;
|
||||||
|
asset_metadata_audit: AssetMetadataAuditTable;
|
||||||
asset_job_status: AssetJobStatusTable;
|
asset_job_status: AssetJobStatusTable;
|
||||||
asset_audit: AssetAuditTable;
|
asset_audit: AssetAuditTable;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
18
server/src/schema/tables/asset-metadata-audit.table.ts
Normal file
18
server/src/schema/tables/asset-metadata-audit.table.ts
Normal 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>;
|
||||||
|
}
|
||||||
46
server/src/schema/tables/asset-metadata.table.ts
Normal file
46
server/src/schema/tables/asset-metadata.table.ts
Normal 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>;
|
||||||
|
}
|
||||||
|
|
@ -423,6 +423,10 @@ export class AssetMediaService extends BaseService {
|
||||||
sidecarPath: sidecarFile?.originalPath,
|
sidecarPath: sidecarFile?.originalPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dto.metadata) {
|
||||||
|
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
if (sidecarFile) {
|
if (sidecarFile) {
|
||||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import {
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetJobsDto,
|
AssetJobsDto,
|
||||||
|
AssetMetadataResponseDto,
|
||||||
|
AssetMetadataUpsertDto,
|
||||||
AssetStatsDto,
|
AssetStatsDto,
|
||||||
UpdateAssetDto,
|
UpdateAssetDto,
|
||||||
mapStats,
|
mapStats,
|
||||||
} from 'src/dtos/asset.dto';
|
} from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.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 { BaseService } from 'src/services/base.service';
|
||||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||||
import { requireElevatedPermission } from 'src/utils/access';
|
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 });
|
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) {
|
async run(auth: AuthDto, dto: AssetJobsDto) {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });
|
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });
|
||||||
|
|
||||||
|
|
@ -313,7 +340,7 @@ export class AssetService extends BaseService {
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateMetadata(dto: ISidecarWriteJob) {
|
private async updateExif(dto: ISidecarWriteJob) {
|
||||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
||||||
if (Object.keys(writes).length > 0) {
|
if (Object.keys(writes).length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export const SYNC_TYPES_ORDER = [
|
||||||
SyncRequestType.PeopleV1,
|
SyncRequestType.PeopleV1,
|
||||||
SyncRequestType.AssetFacesV1,
|
SyncRequestType.AssetFacesV1,
|
||||||
SyncRequestType.UserMetadataV1,
|
SyncRequestType.UserMetadataV1,
|
||||||
|
SyncRequestType.AssetMetadataV1,
|
||||||
];
|
];
|
||||||
|
|
||||||
const throwSessionRequired = () => {
|
const throwSessionRequired = () => {
|
||||||
|
|
@ -156,6 +157,7 @@ export class SyncService extends BaseService {
|
||||||
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
|
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
|
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
|
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
|
||||||
|
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
|
||||||
[SyncRequestType.PartnerAssetExifsV1]: () =>
|
[SyncRequestType.PartnerAssetExifsV1]: () =>
|
||||||
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
|
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
|
||||||
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
|
[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 }) {
|
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
|
||||||
const { type, sessionId, createId } = item;
|
const { type, sessionId, createId } = item;
|
||||||
await this.syncCheckpointRepository.upsertAll([
|
await this.syncCheckpointRepository.upsertAll([
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||||
import {
|
import {
|
||||||
|
AssetMetadataKey,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
AssetType,
|
AssetType,
|
||||||
DatabaseSslMode,
|
DatabaseSslMode,
|
||||||
|
|
@ -465,11 +466,6 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
|
||||||
[SystemMetadataKey.MemoriesState]: MemoriesState;
|
[SystemMetadataKey.MemoriesState]: MemoriesState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
|
||||||
key: T;
|
|
||||||
value: UserMetadata[T];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
albums: {
|
albums: {
|
||||||
defaultAssetOrder: AssetOrder;
|
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>> {
|
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
|
||||||
[UserMetadataKey.Preferences]: DeepPartial<UserPreferences>;
|
[UserMetadataKey.Preferences]: DeepPartial<UserPreferences>;
|
||||||
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
|
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
|
||||||
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
|
[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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
126
server/test/medium/specs/sync/sync-asset-metadata.spec.ts
Normal file
126
server/test/medium/specs/sync/sync-asset-metadata.spec.ts
Normal 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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -41,5 +41,9 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||||
filterNewExternalAssetPaths: vitest.fn(),
|
filterNewExternalAssetPaths: vitest.fn(),
|
||||||
updateByLibraryId: vitest.fn(),
|
updateByLibraryId: vitest.fn(),
|
||||||
getFileSamples: vitest.fn(),
|
getFileSamples: vitest.fn(),
|
||||||
|
getMetadata: vitest.fn(),
|
||||||
|
upsertMetadata: vitest.fn(),
|
||||||
|
getMetadataByKey: vitest.fn(),
|
||||||
|
deleteMetadataByKey: vitest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue