From c9bcae813bc7ce44f90b13fe336212bcc6596f90 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Jun 2025 17:48:43 -0400 Subject: [PATCH] feat: duplicate delete groups api (#19142) --- mobile/openapi/README.md | 2 + mobile/openapi/lib/api/duplicates_api.dart | 79 +++++++++++++++++++ open-api/immich-openapi-specs.json | 68 ++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 17 ++++ .../src/controllers/duplicate.controller.ts | 16 +++- server/src/dtos/duplicate.dto.ts | 8 -- server/src/queries/duplicate.repository.sql | 16 ++++ .../src/repositories/duplicate.repository.ts | 27 ++++++- server/src/services/duplicate.service.ts | 9 +++ .../[[assetId=id]]/+page.svelte | 6 +- 10 files changed, 235 insertions(+), 13 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c5cff5176a..b648b0a709 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -122,6 +122,8 @@ Class | Method | HTTP request | Description *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | +*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | +*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | *FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | *FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | diff --git a/mobile/openapi/lib/api/duplicates_api.dart b/mobile/openapi/lib/api/duplicates_api.dart index 715c6d6112..d8b45d21a2 100644 --- a/mobile/openapi/lib/api/duplicates_api.dart +++ b/mobile/openapi/lib/api/duplicates_api.dart @@ -16,6 +16,85 @@ class DuplicatesApi { final ApiClient apiClient; + /// Performs an HTTP 'DELETE /duplicates/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteDuplicateWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/duplicates/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteDuplicate(String id,) async { + final response = await deleteDuplicateWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /duplicates' operation and returns the [Response]. + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteDuplicatesWithHttpInfo(BulkIdsDto bulkIdsDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/duplicates'; + + // ignore: prefer_final_locals + Object? postBody = bulkIdsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteDuplicates(BulkIdsDto bulkIdsDto,) async { + final response = await deleteDuplicatesWithHttpInfo(bulkIdsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /duplicates' operation and returns the [Response]. Future getAssetDuplicatesWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4ef9ad0bd8..96819184e4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2698,6 +2698,39 @@ } }, "/duplicates": { + "delete": { + "operationId": "deleteDuplicates", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Duplicates" + ] + }, "get": { "operationId": "getAssetDuplicates", "parameters": [], @@ -2732,6 +2765,41 @@ ] } }, + "/duplicates/{id}": { + "delete": { + "operationId": "deleteDuplicate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Duplicates" + ] + } + }, "/faces": { "get": { "operationId": "getFaces", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e9dccc9cc6..f5650a6303 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2286,6 +2286,15 @@ export function getDownloadInfo({ key, downloadInfoDto }: { body: downloadInfoDto }))); } +export function deleteDuplicates({ bulkIdsDto }: { + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/duplicates", oazapfts.json({ + ...opts, + method: "DELETE", + body: bulkIdsDto + }))); +} export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2294,6 +2303,14 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function deleteDuplicate({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/duplicates/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} export function getFaces({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index f62d29d077..f6b09e6e7a 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -1,9 +1,11 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; +import { UUIDParamDto } from 'src/validation'; @ApiTags('Duplicates') @Controller('duplicates') @@ -15,4 +17,16 @@ export class DuplicateController { getAssetDuplicates(@Auth() auth: AuthDto): Promise { return this.service.getDuplicates(auth); } + + @Delete() + @Authenticated() + deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Delete(':id') + @Authenticated() + deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } } diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index b12580ef18..166f18ce8f 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,14 +1,6 @@ -import { IsNotEmpty } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ValidateUUID } from 'src/validation'; export class DuplicateResponseDto { duplicateId!: string; assets!: AssetResponseDto[]; } - -export class ResolveDuplicatesDto { - @IsNotEmpty() - @ValidateUUID({ each: true }) - assetIds!: string[]; -} diff --git a/server/src/queries/duplicate.repository.sql b/server/src/queries/duplicate.repository.sql index f008e57963..727ddd5b8d 100644 --- a/server/src/queries/duplicate.repository.sql +++ b/server/src/queries/duplicate.repository.sql @@ -60,6 +60,22 @@ where "unique"."duplicateId" = "duplicates"."duplicateId" ) +-- DuplicateRepository.delete +update "assets" +set + "duplicateId" = $1 +where + "ownerId" = $2 + and "duplicateId" = $3 + +-- DuplicateRepository.deleteAll +update "assets" +set + "duplicateId" = $1 +where + "ownerId" = $2 + and "duplicateId" in ($3) + -- DuplicateRepository.search begin set diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index b3329e4ca7..73f09aa710 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Kysely, NotNull, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; -import { DummyValue, GenerateSql } from 'src/decorators'; +import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; @@ -78,6 +78,31 @@ export class DuplicateRepository { ); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async delete(userId: string, id: string): Promise { + await this.db + .updateTable('assets') + .set({ duplicateId: null }) + .where('ownerId', '=', userId) + .where('duplicateId', '=', id) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @Chunked({ paramIndex: 1 }) + async deleteAll(userId: string, ids: string[]): Promise { + if (ids.length === 0) { + return; + } + + await this.db + .updateTable('assets') + .set({ duplicateId: null }) + .where('ownerId', '=', userId) + .where('duplicateId', 'in', ids) + .execute(); + } + @GenerateSql({ params: [ { diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index ed6e5f16e2..296c30bc51 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; @@ -20,6 +21,14 @@ export class DuplicateService extends BaseService { })); } + async delete(auth: AuthDto, id: string): Promise { + await this.duplicateRepository.delete(auth.user.id, id); + } + + async deleteAll(auth: AuthDto, dto: BulkIdsDto) { + await this.duplicateRepository.deleteAll(auth.user.id, dto.ids); + } + @OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION }) async handleQueueSearchDuplicates({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 42da091997..f15a20f6d3 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -14,7 +14,7 @@ import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { handleError } from '$lib/utils/handle-error'; import type { AssetResponseDto } from '@immich/sdk'; - import { deleteAssets, updateAssets } from '@immich/sdk'; + import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk'; import { Button, HStack, IconButton, Text } from '@immich/ui'; import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -134,10 +134,10 @@ }; const handleKeepAll = async () => { - const ids = duplicates.flatMap((group) => group.assets.map((asset) => asset.id)); + const ids = duplicates.map(({ duplicateId }) => duplicateId); return withConfirmation( async () => { - await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } }); + await deleteDuplicates({ bulkIdsDto: { ids } }); duplicates = [];