1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-03 05:46:58 +02:00

feat: duplicate delete groups api (#19142)

This commit is contained in:
Jason Rasmussen
2025-06-12 17:48:43 -04:00
committed by GitHub
parent bddb43e1d4
commit c9bcae813b
10 changed files with 235 additions and 13 deletions

View File

@ -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} |

View File

@ -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

View File

@ -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",

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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[];
}

View File

@ -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

View File

@ -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: [
{

View File

@ -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 });

View File

@ -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 = [];