diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3596eaf4b1..3cf6b1ee12 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5978,6 +5978,55 @@ "x-immich-state": "Alpha" } }, + "/duplicates/resolve-all": { + "post": { + "description": "Resolve all duplicate groups by keeping the suggested asset in each group and deleting/trashing duplicates.", + "operationId": "resolveAllDuplicates", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Resolve all duplicate groups", + "tags": [ + "Duplicates" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + }, + { + "version": "v3.0.0", + "state": "Alpha" + } + ], + "x-immich-permission": "duplicate.delete", + "x-immich-state": "Alpha" + } + }, "/duplicates/{id}": { "delete": { "description": "Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index af24b2d21b..89e81ceb2d 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -4727,6 +4727,18 @@ export function resolveDuplicates({ duplicateResolveDto }: { body: duplicateResolveDto }))); } +/** + * Resolve all duplicate groups + */ +export function resolveAllDuplicates(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: BulkIdResponseDto[]; + }>("/duplicates/resolve-all", { + ...opts, + method: "POST" + })); +} /** * Dismiss a duplicate group */ diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index 6502c3b2a9..aa38b3fbb9 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -60,4 +60,17 @@ export class DuplicateController { resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise { return this.service.resolve(auth, dto); } + + @Post('resolve-all') + @HttpCode(HttpStatus.OK) + @Authenticated({ permission: Permission.DuplicateDelete }) + @Endpoint({ + summary: 'Resolve all duplicate groups', + description: + 'Resolve all duplicate groups by keeping the suggested asset in each group and deleting/trashing duplicates.', + history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'), + }) + resolveAllDuplicates(@Auth() auth: AuthDto): Promise { + return this.service.resolveAll(auth); + } } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 6e6f50d2f9..ae4031ecde 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -215,6 +215,53 @@ describe(DuplicateService.name, () => { }); }); + describe('resolveAll (via resolve)', () => { + it('should keep the suggested asset and trash the rest of each group', async () => { + const smallAsset = AssetFactory.from().exif({ fileSizeInByte: 1000 }).build(); + const largeAsset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build(); + const group = { + duplicateId: 'group-1', + assets: [getForDuplicate(smallAsset), getForDuplicate(largeAsset)], + }; + mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue(); + mocks.duplicateRepository.getAll.mockResolvedValue([group]); + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockResolvedValue(group); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([smallAsset.id])); + mocks.album.getByAssetIds.mockResolvedValue(new Map()); + mocks.systemMetadata.get.mockResolvedValue({}); + + await expect(sut.resolveAll(authStub.admin)).resolves.toEqual([{ id: 'group-1', success: true }]); + }); + + it('should resolve multiple duplicate groups', async () => { + const group1Small = AssetFactory.from().exif({ fileSizeInByte: 1000 }).build(); + const group1Large = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build(); + const group2Small = AssetFactory.from().exif({ fileSizeInByte: 500 }).build(); + const group2Large = AssetFactory.from().exif({ fileSizeInByte: 2000 }).build(); + const group1 = { + duplicateId: 'group-1', + assets: [getForDuplicate(group1Small), getForDuplicate(group1Large)], + }; + const group2 = { + duplicateId: 'group-2', + assets: [getForDuplicate(group2Small), getForDuplicate(group2Large)], + }; + mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue(); + mocks.duplicateRepository.getAll.mockResolvedValue([group1, group2]); + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2'])); + mocks.duplicateRepository.get.mockResolvedValueOnce(group1).mockResolvedValueOnce(group2); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['trashed-asset'])); + mocks.album.getByAssetIds.mockResolvedValue(new Map()); + mocks.systemMetadata.get.mockResolvedValue({}); + + await expect(sut.resolveAll(authStub.admin)).resolves.toEqual([ + { id: 'group-1', success: true }, + { id: 'group-2', success: true }, + ]); + }); + }); + describe('resolveGroup (via resolve)', () => { it('should fail if duplicate group not found', async () => { mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['missing-id'])); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 08c7630f91..681ddd6442 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -109,6 +109,22 @@ export class DuplicateService extends BaseService { return results; } + async resolveAll(auth: AuthDto): Promise { + await this.duplicateRepository.cleanupSingletonGroups(auth.user.id); + + const duplicates = await this.duplicateRepository.getAll(auth.user.id); + + const groups = duplicates.map(({ duplicateId, assets }) => { + const mappedAssets = assets.map((asset) => mapAsset(asset, { auth })); + const keepAssetIds = suggestDuplicateKeepAssetIds(mappedAssets); + const keepAssetIdSet = new Set(keepAssetIds); + const trashAssetIds = mappedAssets.map((asset) => asset.id).filter((id) => !keepAssetIdSet.has(id)); + return { duplicateId, keepAssetIds, trashAssetIds }; + }); + + return this.resolve(auth, { groups }); + } + private async resolveGroup(auth: AuthDto, group: DuplicateResolveGroupDto): Promise { const { duplicateId, keepAssetIds, trashAssetIds } = group; 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 f673abec63..b490c281ec 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 @@ -12,7 +12,7 @@ import { locale } from '$lib/stores/preferences.store'; import { handleError } from '$lib/utils/handle-error'; import type { AssetResponseDto } from '@immich/sdk'; - import { createStack, deleteDuplicates, resolveDuplicates, updateAssets } from '@immich/sdk'; + import { createStack, deleteDuplicates, resolveAllDuplicates, resolveDuplicates, updateAssets } from '@immich/sdk'; import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui'; import { mdiCheckOutline, @@ -133,7 +133,7 @@ }; const handleDeduplicateAll = async () => { - // Use server-provided suggestedKeepAssetIds from each group + // Drives the confirmation count and the toast only; the response is per-group, not per-asset. const idsToDelete = duplicates.flatMap((group) => { const keepIds = new Set(group.suggestedKeepAssetIds); return group.assets.map((asset) => asset.id).filter((id) => !keepIds.has(id)); @@ -150,19 +150,7 @@ return withConfirmation( async () => { - // Resolve all groups in a single batch request - const response = await resolveDuplicates({ - duplicateResolveDto: { - groups: duplicates.map((group) => { - const keepIds = new Set(group.suggestedKeepAssetIds); - return { - duplicateId: group.duplicateId, - keepAssetIds: group.suggestedKeepAssetIds, - trashAssetIds: group.assets.map((asset) => asset.id).filter((id) => !keepIds.has(id)), - }; - }), - }, - }); + const response = await resolveAllDuplicates(); // Count failures and show appropriate message const failedCount = response.filter(({ success }) => !success).length;