You've already forked immich
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:
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