mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
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:
parent
26e07548a7
commit
3b3b0f431f
5 changed files with 137 additions and 0 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue