mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 13:57:39 +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 |  | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |  | ||||||
| *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |  | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |  | ||||||
| *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |  | *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 |  | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |  | ||||||
| *FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces |  | *FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces |  | ||||||
| *FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} |  | *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; |   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]. |   /// Performs an HTTP 'GET /duplicates' operation and returns the [Response]. | ||||||
|   Future<Response> getAssetDuplicatesWithHttpInfo() async { |   Future<Response> getAssetDuplicatesWithHttpInfo() async { | ||||||
|     // ignore: prefer_const_declarations |     // ignore: prefer_const_declarations | ||||||
|   | |||||||
| @@ -2698,6 +2698,39 @@ | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "/duplicates": { |     "/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": { |       "get": { | ||||||
|         "operationId": "getAssetDuplicates", |         "operationId": "getAssetDuplicates", | ||||||
|         "parameters": [], |         "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": { |     "/faces": { | ||||||
|       "get": { |       "get": { | ||||||
|         "operationId": "getFaces", |         "operationId": "getFaces", | ||||||
|   | |||||||
| @@ -2286,6 +2286,15 @@ export function getDownloadInfo({ key, downloadInfoDto }: { | |||||||
|         body: 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) { | export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { | ||||||
|     return oazapfts.ok(oazapfts.fetchJson<{ |     return oazapfts.ok(oazapfts.fetchJson<{ | ||||||
|         status: 200; |         status: 200; | ||||||
| @@ -2294,6 +2303,14 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { | |||||||
|         ...opts |         ...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 }: { | export function getFaces({ id }: { | ||||||
|     id: string; |     id: string; | ||||||
| }, opts?: Oazapfts.RequestOpts) { | }, 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 { ApiTags } from '@nestjs/swagger'; | ||||||
|  | import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; | ||||||
| import { AuthDto } from 'src/dtos/auth.dto'; | import { AuthDto } from 'src/dtos/auth.dto'; | ||||||
| import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; | import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; | ||||||
| import { Auth, Authenticated } from 'src/middleware/auth.guard'; | import { Auth, Authenticated } from 'src/middleware/auth.guard'; | ||||||
| import { DuplicateService } from 'src/services/duplicate.service'; | import { DuplicateService } from 'src/services/duplicate.service'; | ||||||
|  | import { UUIDParamDto } from 'src/validation'; | ||||||
|  |  | ||||||
| @ApiTags('Duplicates') | @ApiTags('Duplicates') | ||||||
| @Controller('duplicates') | @Controller('duplicates') | ||||||
| @@ -15,4 +17,16 @@ export class DuplicateController { | |||||||
|   getAssetDuplicates(@Auth() auth: AuthDto): Promise<DuplicateResponseDto[]> { |   getAssetDuplicates(@Auth() auth: AuthDto): Promise<DuplicateResponseDto[]> { | ||||||
|     return this.service.getDuplicates(auth); |     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 { AssetResponseDto } from 'src/dtos/asset-response.dto'; | ||||||
| import { ValidateUUID } from 'src/validation'; |  | ||||||
|  |  | ||||||
| export class DuplicateResponseDto { | export class DuplicateResponseDto { | ||||||
|   duplicateId!: string; |   duplicateId!: string; | ||||||
|   assets!: AssetResponseDto[]; |   assets!: AssetResponseDto[]; | ||||||
| } | } | ||||||
|  |  | ||||||
| export class ResolveDuplicatesDto { |  | ||||||
|   @IsNotEmpty() |  | ||||||
|   @ValidateUUID({ each: true }) |  | ||||||
|   assetIds!: string[]; |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -60,6 +60,22 @@ where | |||||||
|       "unique"."duplicateId" = "duplicates"."duplicateId" |       "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 | -- DuplicateRepository.search | ||||||
| begin | begin | ||||||
| set | set | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; | |||||||
| import { Kysely, NotNull, sql } from 'kysely'; | import { Kysely, NotNull, sql } from 'kysely'; | ||||||
| import { InjectKysely } from 'nestjs-kysely'; | import { InjectKysely } from 'nestjs-kysely'; | ||||||
| import { DB } from 'src/db'; | 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 { MapAsset } from 'src/dtos/asset-response.dto'; | ||||||
| import { AssetType, VectorIndex } from 'src/enum'; | import { AssetType, VectorIndex } from 'src/enum'; | ||||||
| import { probes } from 'src/repositories/database.repository'; | 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({ |   @GenerateSql({ | ||||||
|     params: [ |     params: [ | ||||||
|       { |       { | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { Injectable } from '@nestjs/common'; | import { Injectable } from '@nestjs/common'; | ||||||
| import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; | import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; | ||||||
| import { OnJob } from 'src/decorators'; | import { OnJob } from 'src/decorators'; | ||||||
|  | import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; | ||||||
| import { mapAsset } from 'src/dtos/asset-response.dto'; | import { mapAsset } from 'src/dtos/asset-response.dto'; | ||||||
| import { AuthDto } from 'src/dtos/auth.dto'; | import { AuthDto } from 'src/dtos/auth.dto'; | ||||||
| import { DuplicateResponseDto } from 'src/dtos/duplicate.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 }) |   @OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION }) | ||||||
|   async handleQueueSearchDuplicates({ force }: JobOf<JobName.QUEUE_DUPLICATE_DETECTION>): Promise<JobStatus> { |   async handleQueueSearchDuplicates({ force }: JobOf<JobName.QUEUE_DUPLICATE_DETECTION>): Promise<JobStatus> { | ||||||
|     const { machineLearning } = await this.getConfig({ withCache: false }); |     const { machineLearning } = await this.getConfig({ withCache: false }); | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ | |||||||
|   import { suggestDuplicate } from '$lib/utils/duplicate-utils'; |   import { suggestDuplicate } from '$lib/utils/duplicate-utils'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import type { AssetResponseDto } from '@immich/sdk'; |   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 { Button, HStack, IconButton, Text } from '@immich/ui'; | ||||||
|   import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js'; |   import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js'; | ||||||
|   import { t } from 'svelte-i18n'; |   import { t } from 'svelte-i18n'; | ||||||
| @@ -134,10 +134,10 @@ | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleKeepAll = async () => { |   const handleKeepAll = async () => { | ||||||
|     const ids = duplicates.flatMap((group) => group.assets.map((asset) => asset.id)); |     const ids = duplicates.map(({ duplicateId }) => duplicateId); | ||||||
|     return withConfirmation( |     return withConfirmation( | ||||||
|       async () => { |       async () => { | ||||||
|         await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } }); |         await deleteDuplicates({ bulkIdsDto: { ids } }); | ||||||
|  |  | ||||||
|         duplicates = []; |         duplicates = []; | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user