feat(web): Improve duplicate suggestion (#14947)

* feat: Improve duplicate suggestion

* format

* feat(web): Add deduplication info popup

* fix: lint

* fmt

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Sebastian Schneider 2025-01-07 19:30:11 +01:00 committed by GitHub
parent 23f3e737fd
commit b4c1304b46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 160 additions and 17 deletions

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import FullScreenModal from './full-screen-modal.svelte';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<FullScreenModal title={$t('deduplication_info')} width="auto" {onClose}>
<div class="text-sm dark:text-white">
<p>{$t('deduplication_info_description')}</p>
<ol class="ml-8 mt-2" style="list-style: decimal">
<li>{$t('deduplication_criteria_1')}</li>
<li>{$t('deduplication_criteria_2')}</li>
</ol>
</div>
</FullScreenModal>

View file

@ -3,8 +3,8 @@
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
import { mdiHeart, mdiMagnifyPlus, mdiImageMultipleOutline } from '@mdi/js';
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {

View file

@ -4,7 +4,8 @@
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils';
import { handlePromiseError } from '$lib/utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { navigate } from '$lib/utils/navigation';
import { shortcuts } from '$lib/actions/shortcut';
import { type AssetResponseDto } from '@immich/sdk';
@ -27,7 +28,7 @@
let trashCount = $derived(assets.length - selectedAssetIds.size);
onMount(() => {
const suggestedAsset = suggestDuplicateByFileSize(assets);
const suggestedAsset = suggestDuplicate(assets);
if (!suggestedAsset) {
selectedAssetIds = new SvelteSet(assets[0].id);

View file

@ -16,13 +16,11 @@ import {
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type AssetResponseDto,
type PersonResponseDto,
type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
import { sortBy } from 'lodash-es';
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
@ -332,9 +330,5 @@ export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T]
}
};
export const suggestDuplicateByFileSize = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
return sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
};
// eslint-disable-next-line unicorn/prefer-code-point
export const decodeBase64 = (data: string) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0));

View file

@ -0,0 +1,37 @@
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import type { AssetResponseDto } from '@immich/sdk';
describe('choosing a duplicate', () => {
it('picks the asset with the largest file size', () => {
const assets = [
{ exifInfo: { fileSizeInByte: 300 } },
{ exifInfo: { fileSizeInByte: 200 } },
{ exifInfo: { fileSizeInByte: 100 } },
];
expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]);
});
it('picks the asset with the most exif data if multiple assets have the same file size', () => {
const assets = [
{ exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1 } },
{ exifInfo: { fileSizeInByte: 200, rating: 5 } },
{ exifInfo: { fileSizeInByte: 100, rating: 5 } },
];
expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]);
});
it('returns undefined for an empty array', () => {
const assets: AssetResponseDto[] = [];
expect(suggestDuplicate(assets)).toBeUndefined();
});
it('handles assets with no exifInfo', () => {
const assets = [{ exifInfo: { fileSizeInByte: 200 } }, {}];
expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]);
});
it('handles assets with exifInfo but no fileSizeInByte', () => {
const assets = [{ exifInfo: { rating: 5, fNumber: 1 } }, { exifInfo: { rating: 5 } }];
expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]);
});
});

View file

@ -0,0 +1,30 @@
import { getExifCount } from '$lib/utils/exif-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { sortBy } from 'lodash-es';
/**
* Suggests the best duplicate asset to keep from a list of duplicates.
*
* The best asset is determined by the following criteria:
* - Largest image file size in bytes
* - Largest count of exif data
*
* @param assets List of duplicate assets
* @returns The best asset to keep
*/
export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
let duplicateAssets = sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte ?? 0);
// Update the list to only include assets with the largest file size
duplicateAssets = duplicateAssets.filter(
(asset) => asset.exifInfo?.fileSizeInByte === duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte,
);
// If there are multiple assets with the same file size, sort the list by the count of exif data
if (duplicateAssets.length >= 2) {
duplicateAssets = sortBy(duplicateAssets, getExifCount);
}
// Return the last asset in the list
return duplicateAssets.pop();
};

View file

@ -0,0 +1,29 @@
import { getExifCount } from '$lib/utils/exif-utils';
import type { AssetResponseDto } from '@immich/sdk';
describe('getting the exif count', () => {
it('returns 0 when exifInfo is undefined', () => {
const asset = {};
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
});
it('returns 0 when exifInfo is empty', () => {
const asset = { exifInfo: {} };
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
});
it('returns the correct count of non-null exifInfo properties', () => {
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: null } };
expect(getExifCount(asset as AssetResponseDto)).toBe(2);
});
it('ignores null, undefined and empty properties in exifInfo', () => {
const asset = { exifInfo: { fileSizeInByte: 200, rating: null, fNumber: undefined, description: '' } };
expect(getExifCount(asset as AssetResponseDto)).toBe(1);
});
it('returns the correct count when all exifInfo properties are non-null', () => {
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1, description: 'test' } };
expect(getExifCount(asset as AssetResponseDto)).toBe(4);
});
});

View file

@ -0,0 +1,5 @@
import type { AssetResponseDto } from '@immich/sdk';
export const getExifCount = (asset: AssetResponseDto) => {
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
};