mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge 5074c40834 into af33a78d18
This commit is contained in:
commit
dc4a2d414b
6 changed files with 140 additions and 15 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
|
|
|
|||
|
|
@ -109,6 +109,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue