mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 07:47:41 +09:00 
			
		
		
		
	feat: duplicate delete groups api (#19142)
This commit is contained in:
		
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -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} |  | ||||
|   | ||||
							
								
								
									
										79
									
								
								mobile/openapi/lib/api/duplicates_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										79
									
								
								mobile/openapi/lib/api/duplicates_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -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<Response> deleteDuplicateWithHttpInfo(String id,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final apiPath = r'/duplicates/{id}' | ||||
|       .replaceAll('{id}', id); | ||||
| 
 | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody; | ||||
| 
 | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| 
 | ||||
|     return apiClient.invokeAPI( | ||||
|       apiPath, | ||||
|       'DELETE', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [String] id (required): | ||||
|   Future<void> 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<Response> deleteDuplicatesWithHttpInfo(BulkIdsDto bulkIdsDto,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final apiPath = r'/duplicates'; | ||||
| 
 | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody = bulkIdsDto; | ||||
| 
 | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     const contentTypes = <String>['application/json']; | ||||
| 
 | ||||
| 
 | ||||
|     return apiClient.invokeAPI( | ||||
|       apiPath, | ||||
|       'DELETE', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [BulkIdsDto] bulkIdsDto (required): | ||||
|   Future<void> 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<Response> getAssetDuplicatesWithHttpInfo() async { | ||||
|     // ignore: prefer_const_declarations | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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<DuplicateResponseDto[]> { | ||||
|     return this.service.getDuplicates(auth); | ||||
|   } | ||||
|  | ||||
|   @Delete() | ||||
|   @Authenticated() | ||||
|   deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> { | ||||
|     return this.service.deleteAll(auth, dto); | ||||
|   } | ||||
|  | ||||
|   @Delete(':id') | ||||
|   @Authenticated() | ||||
|   deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { | ||||
|     return this.service.delete(auth, id); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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[]; | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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<void> { | ||||
|     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<void> { | ||||
|     if (ids.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await this.db | ||||
|       .updateTable('assets') | ||||
|       .set({ duplicateId: null }) | ||||
|       .where('ownerId', '=', userId) | ||||
|       .where('duplicateId', 'in', ids) | ||||
|       .execute(); | ||||
|   } | ||||
|  | ||||
|   @GenerateSql({ | ||||
|     params: [ | ||||
|       { | ||||
|   | ||||
| @@ -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<void> { | ||||
|     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<JobName.QUEUE_DUPLICATE_DETECTION>): Promise<JobStatus> { | ||||
|     const { machineLearning } = await this.getConfig({ withCache: false }); | ||||
|   | ||||
| @@ -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 = []; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user