feat(server): resolve all duplicates endpoint (#29665)

* add endpoint to resolve every duplicate group in one request
* keep the suggested asset per group (largest file / most EXIF) and trash the rest, reusing the existing resolve flow
This commit is contained in:
wallacewong 2026-07-08 12:24:58 +01:00
parent 26e07548a7
commit 3b3b0f431f
5 changed files with 137 additions and 0 deletions

View file

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

View file

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

View file

@ -60,4 +60,17 @@ export class DuplicateController {
resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise<BulkIdResponseDto[]> {
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<BulkIdResponseDto[]> {
return this.service.resolveAll(auth);
}
}

View file

@ -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']));

View file

@ -110,6 +110,22 @@ export class DuplicateService extends BaseService {
return results;
}
async resolveAll(auth: AuthDto): Promise<BulkIdResponseDto[]> {
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<BulkIdResponseDto> {
const { duplicateId, keepAssetIds, trashAssetIds } = group;