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 | Bin 37361 -> 37582 bytes mobile/openapi/lib/api/duplicates_api.dart | Bin 1932 -> 4120 bytes 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, 154 insertions(+), 13 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c5cff5176a519e48bee688d4f621312c82726fe9..b648b0a709b6e8dede1563b85d00ec5fd1fe0b42 100644 GIT binary patch delta 159 zcmeyknCaY7rVa09_)}7IQcF@@N(*u_lM_o)Co{@Q`9j1}U}BmIH40i)601{U?f&c&j delta 14 WcmX@NlZq6t#vR$5Y8lxh={T3nEySDb36 zkdm4MG{OaDMR;aOhDS+BfoEP?z6Qh;pdmV%3W>#)dC3aZKqEj_O}1rnQ31O~9W0`t zpjVVykdv64>X?(Gp$>AUx(-mI=H@0QX+}1PPbM#5o)k}^!^`qBQ!pHjvO^P$tp%8T{&B^vmDRwEzk5x#5vq=oyVwB)T7zs3y6K1^5 zWL>6yUIkkuTPE*eS|+5PSO7@|nfZD8S;hHz>XVb0tzkX@1}!yxfHjzlHBej(cQ7<@ ZOfF!N(?AP)R1YJ<4H^fVS1~?f1pvBT`f~sP delta 12 TcmbQC(8Iss2;=4et`n>PAKwI{ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4ef9ad0bd..96819184e 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 e9dccc9cc..f5650a630 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 f62d29d07..f6b09e6e7 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 b12580ef1..166f18ce8 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 f008e5796..727ddd5b8 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 b3329e4ca..73f09aa71 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 ed6e5f16e..296c30bc5 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 42da09199..f15a20f6d 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 = [];