diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e73760a3de..2417e0b342 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5977,6 +5977,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 8983de8ef7..2c3dc5d4ef 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 7ea300f88a..517fbc9cd1 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -110,6 +110,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;