From cf1738c2318186d9e39e38e1be9ed2d6fe336b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 2 Sep 2025 11:17:49 +0200 Subject: [PATCH 01/11] feat(web): add external/neutral duplicate source preference --- .../duplicates/duplicate-asset.svelte | 8 +++--- .../duplicates-compare-control.svelte | 26 +++++++++---------- web/src/lib/stores/duplicate-preferences.ts | 10 +++++++ web/src/lib/utils/duplicate-selection.ts | 22 ++++++++++++++++ web/src/lib/utils/duplicate-utils.ts | 24 +++++++++++++++++ .../[[assetId=id]]/+page.svelte | 15 +++++++++-- 6 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 web/src/lib/stores/duplicate-preferences.ts create mode 100644 web/src/lib/utils/duplicate-selection.ts diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 71f953f3ac..b1e80a52ec 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -17,8 +17,8 @@ let { asset, isSelected, onSelectAsset, onViewAsset }: Props = $props(); - let isFromExternalLibrary = $derived(!!asset.libraryId); - let assetData = $derived(JSON.stringify(asset, null, 2)); + const isFromExternalLibrary = $derived(!!asset.libraryId); + const assetData = $derived(JSON.stringify(asset, null, 2));
+ + +
+ + + + + +
+ + +
+ + +
+
+ + + diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index fc62419c6a..872212ddc8 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -21,7 +21,7 @@ import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui'; import { suggestDuplicateWithPrefs } from '$lib/utils/duplicate-utils'; import { duplicateTiePreference } from '$lib/stores/duplicate-preferences'; - + import DuplicatesSettingsModal from '$lib/modals/DuplicatesSettingsModal.svelte'; import { mdiCheckOutline, mdiChevronLeft, @@ -31,6 +31,7 @@ mdiPageFirst, mdiPageLast, mdiTrashCanOutline, + mdiCogOutline, } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -233,37 +234,21 @@ {#snippet buttons()} - + @@ -50,10 +61,10 @@ size="small" variant="ghost" class="rounded-none" - color={tiePreferenceLocal === 'default' ? 'primary' : 'secondary'} - aria-pressed={tiePreferenceLocal === 'default'} + color={tiePreferenceLocal === undefined ? 'primary' : 'secondary'} + aria-pressed={tiePreferenceLocal === undefined} title={$t('deduplicate_prefer_default')} - onclick={() => (tiePreferenceLocal = 'default')} + onclick={() => (tiePreferenceLocal = undefined)} > @@ -61,10 +72,12 @@ size="small" variant="ghost" class="rounded-none" - color={tiePreferenceLocal === 'internal' ? 'primary' : 'secondary'} - aria-pressed={tiePreferenceLocal === 'internal'} + color={findDuplicateTiePreference(tiePreferenceLocal, 'source')?.priority === 'internal' + ? 'primary' + : 'secondary'} + aria-pressed={findDuplicateTiePreference(tiePreferenceLocal, 'source')?.priority === 'internal'} title={$t('deduplicate_prefer_internal')} - onclick={() => (tiePreferenceLocal = 'internal')} + onclick={() => (tiePreferenceLocal = [makeSourcePref('internal')])} > diff --git a/web/src/lib/stores/duplicate-preferences.ts b/web/src/lib/stores/duplicate-preferences.ts deleted file mode 100644 index 23cf47a9b8..0000000000 --- a/web/src/lib/stores/duplicate-preferences.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { writable } from 'svelte/store'; - -export type TiePreference = 'default' | 'external' | 'internal'; - -export const duplicateTiePreference = writable('default'); diff --git a/web/src/lib/stores/duplicate-tie-preferences.ts b/web/src/lib/stores/duplicate-tie-preferences.ts new file mode 100644 index 0000000000..94c410ed3e --- /dev/null +++ b/web/src/lib/stores/duplicate-tie-preferences.ts @@ -0,0 +1,22 @@ +import { writable } from 'svelte/store'; + +/** + * Current (optional) rule: keep by source. + * To add more rules later, extend `PreferenceItem` with new + * `{ variant: 'x'; priority: string[] }` types. + * `undefined` = no saved preference (fallback to default). + */ +export type SourcePreference = { variant: 'source'; priority: 'internal' | 'external' }; + +export type PreferenceItem = SourcePreference; + +export type DuplicateTiePreferences = PreferenceItem[]; +export const duplicateTiePreference = writable(undefined); + +export const findDuplicateTiePreference = ( + preference: DuplicateTiePreferences | undefined, + variant: T, +): Extract | undefined => + preference?.find( + (preference): preference is Extract => preference.variant === variant, + ); diff --git a/web/src/lib/utils/duplicate-selection.ts b/web/src/lib/utils/duplicate-selection.ts deleted file mode 100644 index b9dd190e2a..0000000000 --- a/web/src/lib/utils/duplicate-selection.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { TiePreference } from '$lib/stores/duplicate-preferences'; -import { getExifCount } from '$lib/utils/exif-utils'; -import type { AssetResponseDto } from '@immich/sdk'; - -const sizeOf = (assets: AssetResponseDto) => assets.exifInfo?.fileSizeInByte ?? 0; -const isExternal = (assets: AssetResponseDto) => !!assets.libraryId; - -export function selectDefaultByCurrentHeuristic(assets: AssetResponseDto[]): AssetResponseDto { - const bestSize = Math.max(...assets.map( - (assets) => sizeOf(assets)) - ); - const sizeCandidates = assets.filter( - (assets) => sizeOf(assets) === bestSize, - ); - - if (sizeCandidates.length <= 1) { - return sizeCandidates[0] ?? assets[0]; - } - - const bestExif = Math.max(...sizeCandidates.map( - (assets)=> getExifCount(assets)) - ); - const exifCandidates = sizeCandidates.filter( - (assets) => getExifCount(assets) === bestExif - ); - - return exifCandidates.at(-1) ?? assets[0]; -} - -export function applyLibraryTieBreaker( - assets: AssetResponseDto[], - current: AssetResponseDto, - preference: TiePreference, -): AssetResponseDto { - if (preference === 'default'){ - return current; - } - - const bestSize = Math.max(...assets.map( - (assets) => sizeOf(assets)) - ); - const sizeCandidates = assets.filter( - (assets) => sizeOf(assets) === bestSize - ); - const bestExif = Math.max(...sizeCandidates.map(getExifCount)); - const candidates = sizeCandidates.filter( - (assets) => getExifCount(assets) === bestExif - ); - - if (candidates.length <= 1){ - return current; - } - - if (preference === 'external') { - const external = candidates.find(isExternal); - return external ?? current; - } - - if (preference === 'internal') { - const internal = candidates.find( - (assets) => !isExternal(assets)); - return internal ?? current; - } - - return current; -} diff --git a/web/src/lib/utils/duplicate-utils.spec.ts b/web/src/lib/utils/duplicate-utils.spec.ts index 4fa427989a..bb00ac71c1 100644 --- a/web/src/lib/utils/duplicate-utils.spec.ts +++ b/web/src/lib/utils/duplicate-utils.spec.ts @@ -1,6 +1,14 @@ -import { suggestDuplicate } from '$lib/utils/duplicate-utils'; +import { DuplicateSelection } from '$lib/utils/duplicate-utils'; import type { AssetResponseDto } from '@immich/sdk'; +const duplicateSelector = new DuplicateSelection(); + + +const suggestDuplicate = ( + assets: AssetResponseDto[], + pref = undefined, +) => duplicateSelector.suggestDuplicate(assets, pref); + describe('choosing a duplicate', () => { it('picks the asset with the largest file size', () => { const assets = [ diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts index 09220a30ec..f4a6dd4e89 100644 --- a/web/src/lib/utils/duplicate-utils.ts +++ b/web/src/lib/utils/duplicate-utils.ts @@ -1,53 +1,63 @@ -import type { TiePreference } from '$lib/stores/duplicate-preferences'; -import { applyLibraryTieBreaker, selectDefaultByCurrentHeuristic } from '$lib/utils/duplicate-selection'; +import { type DuplicateTiePreferences, findDuplicateTiePreference } from '$lib/stores/duplicate-tie-preferences'; 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, (assets) => assets.exifInfo?.fileSizeInByte ?? 0); - duplicateAssets = duplicateAssets.filter( - (assets) => assets.exifInfo?.fileSizeInByte === duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte, - ); +const sizeOf = (asset: AssetResponseDto) => asset.exifInfo?.fileSizeInByte ?? 0; +const isExternal = (asset: AssetResponseDto) => Boolean(asset.libraryId); - // 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); +export class DuplicateSelection { + /** + * 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 + * - Optional source preference (internal vs external) + * + * @param assets List of duplicate assets + * @param preference Preference for selecting duplicates + * @returns The best asset to keep + * + */ + public suggestDuplicate = ( + assets: AssetResponseDto[], + preference: DuplicateTiePreferences | undefined, + ): AssetResponseDto | undefined => { + if (!assets.length) return; + + // first filter by size and exif count + let candidates = this.filterBySizeAndExif(assets); + + // then filter by source preference if available + const source = findDuplicateTiePreference(preference, 'source'); + if (source && candidates.length > 1) { + candidates = this.filterBySource(candidates, source.priority); + } + + // Return the best assets + return candidates[0]; + }; + + private filterBySizeAndExif(assets: AssetResponseDto[]): AssetResponseDto[] { + const maxSize = Math.max(...assets.map(sizeOf)); + const sizeFiltered = assets.filter((assets) => sizeOf(assets) === maxSize); + + const maxExif = Math.max(...sizeFiltered.map(getExifCount)); + return sizeFiltered.filter((assets) => getExifCount(assets) === maxExif); } - // Return the last asset in the list - return duplicateAssets.pop(); -}; - -export const suggestDuplicateWithPrefs = ( - assets: AssetResponseDto[], - preference: TiePreference, -): AssetResponseDto | undefined => { - const base = suggestDuplicate(assets) ?? selectDefaultByCurrentHeuristic(assets); - return applyLibraryTieBreaker(assets, base, preference); -}; - -export const buildKeepSelectionForGroup = ( - group: AssetResponseDto[], - preference: TiePreference, -): { id: string; action: 'keep' | 'trash' }[] => { - const keep = suggestDuplicateWithPrefs(group, preference) ?? group[0]; - return group.map((assets) => ({ id: assets.id, action: assets.id === keep.id ? 'keep' : 'trash' })); -}; - -export const buildKeepSelectionForAll = ( - groups: AssetResponseDto[][], - preference: TiePreference, -): { id: string; action: 'keep' | 'trash' }[] => { - return groups.flatMap((groups) => buildKeepSelectionForGroup(groups, preference)); -}; + private filterBySource(assets: AssetResponseDto[], priority: 'internal' | 'external'): AssetResponseDto[] { + const filtered = assets.filter((assets) => { + if (priority == 'external') { + return isExternal(assets); + } else { + return !isExternal(assets); + } + }); + if (filtered.length > 0) { + return filtered; + } else { + return assets; + } + } +} diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index c0d105b38c..5c94aaf31d 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,8 +19,6 @@ import type { AssetResponseDto } from '@immich/sdk'; import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk'; import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui'; - import { suggestDuplicateWithPrefs } from '$lib/utils/duplicate-utils'; - import { duplicateTiePreference } from '$lib/stores/duplicate-preferences'; import DuplicatesSettingsModal from '$lib/modals/DuplicatesSettingsModal.svelte'; import { mdiCheckOutline, @@ -35,6 +33,10 @@ } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { DuplicateSelection } from '$lib/utils/duplicate-utils'; + import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences'; + + const duplicateSelector = new DuplicateSelection(); interface Props { data: PageData; @@ -133,7 +135,9 @@ }; const handleDeduplicateAll = async () => { - const keepCandidates = duplicates.map((group) => suggestDuplicateWithPrefs(group.assets, $duplicateTiePreference)); + const keepCandidates = duplicates.map((group) => + duplicateSelector.suggestDuplicate(group.assets, $duplicateTiePreference), + ); const idsToKeep: (string | undefined)[] = keepCandidates.map((assets) => assets?.id); @@ -237,7 +241,7 @@ From 25590b4a964b01f95c5d2af57a0c7bb0fff8ad45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 20 Oct 2025 18:24:34 +0200 Subject: [PATCH 07/11] feat(web): add new test --- web/src/lib/utils/duplicate-utils.spec.ts | 32 +++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/web/src/lib/utils/duplicate-utils.spec.ts b/web/src/lib/utils/duplicate-utils.spec.ts index bb00ac71c1..4a930b5595 100644 --- a/web/src/lib/utils/duplicate-utils.spec.ts +++ b/web/src/lib/utils/duplicate-utils.spec.ts @@ -1,13 +1,11 @@ import { DuplicateSelection } from '$lib/utils/duplicate-utils'; import type { AssetResponseDto } from '@immich/sdk'; +import type { SourcePreference } from '$lib/stores/duplicate-tie-preferences'; const duplicateSelector = new DuplicateSelection(); - -const suggestDuplicate = ( - assets: AssetResponseDto[], - pref = undefined, -) => duplicateSelector.suggestDuplicate(assets, pref); +const suggestDuplicate = (assets: AssetResponseDto[], pref = undefined) => + duplicateSelector.suggestDuplicate(assets, pref); describe('choosing a duplicate', () => { it('picks the asset with the largest file size', () => { @@ -42,4 +40,28 @@ describe('choosing a duplicate', () => { const assets = [{ exifInfo: { rating: 5, fNumber: 1 } }, { exifInfo: { rating: 5 } }]; expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]); }); + + it('respects source preference when provided', () => { + const assets = [ + { exifInfo: { fileSizeInByte: 200 }, libraryId: null }, // internal + { exifInfo: { fileSizeInByte: 200 }, libraryId: 'lib1' }, // external + ]; + const preference: SourcePreference[] = [ + { variant: 'source', priority: 'external' }, + ]; + expect(duplicateSelector.suggestDuplicate(assets as AssetResponseDto[], preference)).toEqual(assets[1]); + }); + + it('falls back to size and exif when source preference yields no candidates', () => { + const assets = [ + { exifInfo: { fileSizeInByte: 200 }, libraryId: null }, // internal + { exifInfo: { fileSizeInByte: 200 }, libraryId: null }, // internal + ]; + const preference: SourcePreference[] = [ + { variant: 'source', priority: 'external' }, + ]; + expect(duplicateSelector.suggestDuplicate(assets as AssetResponseDto[], preference)).toEqual(assets[0]); + }); + + }); From 776433ed1cf6a843cb65ff338bf086d11c81e870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 20 Oct 2025 18:33:14 +0200 Subject: [PATCH 08/11] feat(web): renamed PreferenceItem to PreferenceDuplicateTieItem --- web/src/lib/stores/duplicate-tie-preferences.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/lib/stores/duplicate-tie-preferences.ts b/web/src/lib/stores/duplicate-tie-preferences.ts index 94c410ed3e..f5b123d80c 100644 --- a/web/src/lib/stores/duplicate-tie-preferences.ts +++ b/web/src/lib/stores/duplicate-tie-preferences.ts @@ -8,15 +8,15 @@ import { writable } from 'svelte/store'; */ export type SourcePreference = { variant: 'source'; priority: 'internal' | 'external' }; -export type PreferenceItem = SourcePreference; +export type PreferenceDuplicateTieItem = SourcePreference; -export type DuplicateTiePreferences = PreferenceItem[]; +export type DuplicateTiePreferences = PreferenceDuplicateTieItem[]; export const duplicateTiePreference = writable(undefined); -export const findDuplicateTiePreference = ( +export const findDuplicateTiePreference = ( preference: DuplicateTiePreferences | undefined, variant: T, -): Extract | undefined => +): Extract | undefined => preference?.find( - (preference): preference is Extract => preference.variant === variant, + (preference): preference is Extract => preference.variant === variant, ); From 19a14d0b7228e49b55c58a03fbe9e85c8ae24681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 13 Nov 2025 18:06:07 +0100 Subject: [PATCH 09/11] refactor(web): address review feedback --- .../duplicates-compare-control.svelte | 28 +++--- .../lib/modals/DuplicatesSettingsModal.svelte | 19 ++-- ...ts => duplicate-tie-preferences.svelte.ts} | 15 ++- web/src/lib/utils/duplicate-utils.spec.ts | 13 ++- web/src/lib/utils/duplicate-utils.ts | 94 ++++++++----------- .../[[assetId=id]]/+page.svelte | 14 +-- 6 files changed, 83 insertions(+), 100 deletions(-) rename web/src/lib/stores/{duplicate-tie-preferences.ts => duplicate-tie-preferences.svelte.ts} (61%) diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 0f1e610d34..8d10d7512f 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -5,7 +5,6 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; - import { DuplicateSelection } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; @@ -13,8 +12,8 @@ import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; - import { duplicateTiePreference, type DuplicateTiePreferences } from '$lib/stores/duplicate-tie-preferences'; - import { get } from 'svelte/store'; + import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; + import {suggestBestDuplicate} from "$lib/utils/duplicate-utils"; interface Props { assets: AssetResponseDto[]; onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; @@ -25,24 +24,19 @@ const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id); - const duplicateSelector = new DuplicateSelection(); - let tiePreferenceLocal: DuplicateTiePreferences | undefined = get(duplicateTiePreference); - let selectedAssetIds = $state(new SvelteSet()); let trashCount = $derived(assets.length - selectedAssetIds.size); - const autoSelect = () => { - const suggested = duplicateSelector.suggestDuplicate(assets, tiePreferenceLocal) ?? assets[0]; - selectedAssetIds = new SvelteSet([suggested.id]); - }; + $effect(() => { + if (assets.length === 0) { + selectedAssetIds = new SvelteSet(); + return; + } - onMount(() => { - autoSelect(); - const unsub = duplicateTiePreference.subscribe((newPref) => { - tiePreferenceLocal = newPref; - autoSelect(); - }); - onDestroy(unsub); + const suggestedAsset = + suggestBestDuplicate(assets, duplicateTiePreference.value) ?? assets[0]; + + selectedAssetIds = new SvelteSet([suggestedAsset.id]); }); onDestroy(() => { diff --git a/web/src/lib/modals/DuplicatesSettingsModal.svelte b/web/src/lib/modals/DuplicatesSettingsModal.svelte index 565a609ef2..8b513d4544 100644 --- a/web/src/lib/modals/DuplicatesSettingsModal.svelte +++ b/web/src/lib/modals/DuplicatesSettingsModal.svelte @@ -3,15 +3,13 @@ import { t } from 'svelte-i18n'; import { duplicateTiePreference, - type DuplicateTiePreferences, findDuplicateTiePreference, type SourcePreference, - } from '$lib/stores/duplicate-tie-preferences'; - import { get } from 'svelte/store'; + setDuplicateTiePreference, + } from '$lib/stores/duplicate-tie-preferences.svelte'; import { mdiCogOutline } from '@mdi/js'; - const initialPref = get(duplicateTiePreference); - let tiePreferenceLocal = $state(initialPref); + let tiePreferenceLocal = $state(duplicateTiePreference.value); interface Props { onClose: () => void; @@ -19,18 +17,17 @@ let { onClose }: Props = $props(); - const cancel = () => onClose(); const confirm = () => { - duplicateTiePreference.set(tiePreferenceLocal); + setDuplicateTiePreference(tiePreferenceLocal); onClose(); }; + const resetToDefault = () => { tiePreferenceLocal = undefined; }; - function makeSourcePref(priority: 'internal' | 'external'): SourcePreference { - return { variant: 'source', priority }; - } + const makeSourcePref = (priority: 'internal' | 'external'): SourcePreference => ({variant: 'source', priority}); + @@ -90,7 +87,7 @@
- From 40d93fd90b1cc205d2570f7fc544d5cb5dab489f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 13 Nov 2025 18:14:35 +0100 Subject: [PATCH 10/11] refactor(web): address review feedback --- i18n/en.json | 2 +- .../duplicates/duplicates-compare-control.svelte | 5 ++--- .../lib/modals/DuplicatesSettingsModal.svelte | 7 +++---- .../stores/duplicate-tie-preferences.svelte.ts | 6 ++++-- web/src/lib/utils/duplicate-utils.spec.ts | 16 ++++------------ web/src/lib/utils/duplicate-utils.ts | 2 +- .../[[assetId=id]]/+page.svelte | 8 ++------ 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index a26053af78..742efb1395 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -884,8 +884,8 @@ "downloading_media": "Downloading media", "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", - "duplicates_settings": "Duplicates Settings", "duplicates_description": "Resolve each group by indicating which, if any, are duplicates", + "duplicates_settings": "Duplicates Settings", "duration": "Duration", "edit": "Edit", "edit_album": "Edit album", diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 8d10d7512f..e5f3b00429 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -13,7 +13,7 @@ import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; - import {suggestBestDuplicate} from "$lib/utils/duplicate-utils"; + import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; interface Props { assets: AssetResponseDto[]; onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; @@ -33,8 +33,7 @@ return; } - const suggestedAsset = - suggestBestDuplicate(assets, duplicateTiePreference.value) ?? assets[0]; + const suggestedAsset = suggestBestDuplicate(assets, duplicateTiePreference.value) ?? assets[0]; selectedAssetIds = new SvelteSet([suggestedAsset.id]); }); diff --git a/web/src/lib/modals/DuplicatesSettingsModal.svelte b/web/src/lib/modals/DuplicatesSettingsModal.svelte index 8b513d4544..8e89795698 100644 --- a/web/src/lib/modals/DuplicatesSettingsModal.svelte +++ b/web/src/lib/modals/DuplicatesSettingsModal.svelte @@ -9,14 +9,14 @@ } from '$lib/stores/duplicate-tie-preferences.svelte'; import { mdiCogOutline } from '@mdi/js'; + let { onClose }: Props = $props(); + let tiePreferenceLocal = $state(duplicateTiePreference.value); interface Props { onClose: () => void; } - let { onClose }: Props = $props(); - const confirm = () => { setDuplicateTiePreference(tiePreferenceLocal); onClose(); @@ -26,8 +26,7 @@ tiePreferenceLocal = undefined; }; - const makeSourcePref = (priority: 'internal' | 'external'): SourcePreference => ({variant: 'source', priority}); - + const makeSourcePref = (priority: 'internal' | 'external'): SourcePreference => ({ variant: 'source', priority }); diff --git a/web/src/lib/stores/duplicate-tie-preferences.svelte.ts b/web/src/lib/stores/duplicate-tie-preferences.svelte.ts index 6f14f5a0ec..b4ce342fc1 100644 --- a/web/src/lib/stores/duplicate-tie-preferences.svelte.ts +++ b/web/src/lib/stores/duplicate-tie-preferences.svelte.ts @@ -12,7 +12,7 @@ export type PreferenceDuplicateTieItem = SourcePreference; export let duplicateTiePreference = $state<{ value: DuplicateTiePreferencesSvelte | undefined; -}>({value: undefined}); +}>({ value: undefined }); export const findDuplicateTiePreference = ( preference: DuplicateTiePreferencesSvelte | undefined, @@ -22,6 +22,8 @@ export const findDuplicateTiePreference = => preference.variant === variant, ); -export function setDuplicateTiePreference(nextDuplicateTiePreferences: DuplicateTiePreferencesSvelte | undefined): void { +export function setDuplicateTiePreference( + nextDuplicateTiePreferences: DuplicateTiePreferencesSvelte | undefined, +): void { duplicateTiePreference.value = nextDuplicateTiePreferences; } diff --git a/web/src/lib/utils/duplicate-utils.spec.ts b/web/src/lib/utils/duplicate-utils.spec.ts index a75de70024..5c6aad1cac 100644 --- a/web/src/lib/utils/duplicate-utils.spec.ts +++ b/web/src/lib/utils/duplicate-utils.spec.ts @@ -1,10 +1,8 @@ -import type { AssetResponseDto } from '@immich/sdk'; import type { SourcePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; +import type { AssetResponseDto } from '@immich/sdk'; - -const suggestDuplicate = (assets: AssetResponseDto[]) => - suggestBestDuplicate(assets, undefined); +const suggestDuplicate = (assets: AssetResponseDto[]) => suggestBestDuplicate(assets, undefined); describe('choosing a duplicate', () => { it('picks the asset with the largest file size', () => { @@ -45,9 +43,7 @@ describe('choosing a duplicate', () => { { exifInfo: { fileSizeInByte: 200 }, libraryId: null }, // internal { exifInfo: { fileSizeInByte: 200 }, libraryId: 'lib1' }, // external ]; - const preference: SourcePreference[] = [ - { variant: 'source', priority: 'external' }, - ]; + const preference: SourcePreference[] = [{ variant: 'source', priority: 'external' }]; expect(suggestBestDuplicate(assets as AssetResponseDto[], preference)).toEqual(assets[1]); }); @@ -56,11 +52,7 @@ describe('choosing a duplicate', () => { { exifInfo: { fileSizeInByte: 200 }, libraryId: null }, // internal { exifInfo: { fileSizeInByte: 200 }, libraryId: null }, // internal ]; - const preference: SourcePreference[] = [ - { variant: 'source', priority: 'external' }, - ]; + const preference: SourcePreference[] = [{ variant: 'source', priority: 'external' }]; expect(suggestBestDuplicate(assets as AssetResponseDto[], preference)).toEqual(assets[0]); }); - - }); diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts index d0ca524523..d139dfaf56 100644 --- a/web/src/lib/utils/duplicate-utils.ts +++ b/web/src/lib/utils/duplicate-utils.ts @@ -25,7 +25,7 @@ export function suggestBestDuplicate( assets: AssetResponseDto[], preference: DuplicateTiePreferencesSvelte | undefined, ): AssetResponseDto | undefined { - if (!assets.length){ + if (!assets.length) { return; } let candidates = filterBySizeAndExif(assets); diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6c7092539e..b8a55a04f1 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -30,9 +30,7 @@ import { t } from 'svelte-i18n'; import type { PageData } from './$types'; import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; - import {suggestBestDuplicate} from "$lib/utils/duplicate-utils"; - - + import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; interface Props { data: PageData; @@ -129,9 +127,7 @@ }; const handleDeduplicateAll = async () => { - const keepCandidates = duplicates.map((group) => - suggestBestDuplicate(group.assets, duplicateTiePreference.value), - ); + const keepCandidates = duplicates.map((group) => suggestBestDuplicate(group.assets, duplicateTiePreference.value)); const idsToKeep: (string | undefined)[] = keepCandidates.map((assets) => assets?.id); From af9c58ef95262101f013ec7d1c769eb42f61d4d2 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Fri, 28 Nov 2025 17:49:36 +0100 Subject: [PATCH 11/11] chore: cleanup --- web/eslint.config.js | 1 + .../duplicates-compare-control.svelte | 8 +- .../lib/modals/DuplicatesSettingsModal.svelte | 145 ++++++++---------- ...plicate-tie-preferences-manager.svelte.ts} | 11 +- web/src/lib/utils/duplicate-utils.spec.ts | 2 +- web/src/lib/utils/duplicate-utils.ts | 17 +- .../[[assetId=id]]/+page.svelte | 18 +-- 7 files changed, 88 insertions(+), 114 deletions(-) rename web/src/lib/stores/{duplicate-tie-preferences.svelte.ts => duplicate-tie-preferences-manager.svelte.ts} (66%) diff --git a/web/eslint.config.js b/web/eslint.config.js index f8e6cdd9c6..f139634f11 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -130,6 +130,7 @@ export default typescriptEslint.config( '@typescript-eslint/require-await': 'error', 'object-shorthand': ['error', 'always'], 'svelte/no-navigation-without-resolve': 'off', + 'svelte/no-unnecessary-state-wrap': ['error', { allowReassign: true }], }, }, { diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index e5f3b00429..06ad51e4a3 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,16 +4,16 @@ import Portal from '$lib/elements/Portal.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences-manager.svelte'; import { handlePromiseError } from '$lib/utils'; + import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; - import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; - import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; interface Props { assets: AssetResponseDto[]; onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; @@ -116,7 +116,7 @@ ]} /> -
+
diff --git a/web/src/lib/modals/DuplicatesSettingsModal.svelte b/web/src/lib/modals/DuplicatesSettingsModal.svelte index 8e89795698..2e48d3da80 100644 --- a/web/src/lib/modals/DuplicatesSettingsModal.svelte +++ b/web/src/lib/modals/DuplicatesSettingsModal.svelte @@ -1,99 +1,86 @@ - - -
-
-

{$t('deduplicate_source_preference')}

+ (confirmed ? handleConfirm() : onClose())} +> + {#snippet promptSnippet()} + + {$t('deduplicate_source_preference')} -
+ - - -
-
- -
- + + - -
- - -
-
-
-
+ + {/snippet} + diff --git a/web/src/lib/stores/duplicate-tie-preferences.svelte.ts b/web/src/lib/stores/duplicate-tie-preferences-manager.svelte.ts similarity index 66% rename from web/src/lib/stores/duplicate-tie-preferences.svelte.ts rename to web/src/lib/stores/duplicate-tie-preferences-manager.svelte.ts index b4ce342fc1..075af36328 100644 --- a/web/src/lib/stores/duplicate-tie-preferences.svelte.ts +++ b/web/src/lib/stores/duplicate-tie-preferences-manager.svelte.ts @@ -10,20 +10,15 @@ export type DuplicateTiePreferencesSvelte = PreferenceDuplicateTieItem[]; export type PreferenceDuplicateTieItem = SourcePreference; -export let duplicateTiePreference = $state<{ +export const duplicateTiePreference = $state<{ value: DuplicateTiePreferencesSvelte | undefined; }>({ value: undefined }); export const findDuplicateTiePreference = ( preference: DuplicateTiePreferencesSvelte | undefined, variant: T, -): Extract | undefined => - preference?.find( - (preference): preference is Extract => preference.variant === variant, - ); +) => preference?.find((preference) => preference.variant === variant); -export function setDuplicateTiePreference( - nextDuplicateTiePreferences: DuplicateTiePreferencesSvelte | undefined, -): void { +export function setDuplicateTiePreference(nextDuplicateTiePreferences: DuplicateTiePreferencesSvelte | undefined) { duplicateTiePreference.value = nextDuplicateTiePreferences; } diff --git a/web/src/lib/utils/duplicate-utils.spec.ts b/web/src/lib/utils/duplicate-utils.spec.ts index 5c6aad1cac..8774aa63ca 100644 --- a/web/src/lib/utils/duplicate-utils.spec.ts +++ b/web/src/lib/utils/duplicate-utils.spec.ts @@ -1,4 +1,4 @@ -import type { SourcePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; +import type { SourcePreference } from '$lib/stores/duplicate-tie-preferences-manager.svelte'; import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; import type { AssetResponseDto } from '@immich/sdk'; diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts index d139dfaf56..a619174c67 100644 --- a/web/src/lib/utils/duplicate-utils.ts +++ b/web/src/lib/utils/duplicate-utils.ts @@ -1,12 +1,11 @@ import { type DuplicateTiePreferencesSvelte, findDuplicateTiePreference, -} from '$lib/stores/duplicate-tie-preferences.svelte'; +} from '$lib/stores/duplicate-tie-preferences-manager.svelte'; import { getExifCount } from '$lib/utils/exif-utils'; import type { AssetResponseDto } from '@immich/sdk'; const sizeOf = (asset: AssetResponseDto) => asset.exifInfo?.fileSizeInByte ?? 0; -const isExternal = (asset: AssetResponseDto) => Boolean(asset.libraryId); /** * Suggests the best duplicate asset to keep from a list of duplicates. @@ -16,16 +15,12 @@ const isExternal = (asset: AssetResponseDto) => Boolean(asset.libraryId); * - Largest count of exif data * - Optional source preference (internal vs external) * - * @param assets List of duplicate assets - * @param preference Preference for selecting duplicates - * @returns The best asset to keep - * */ export function suggestBestDuplicate( assets: AssetResponseDto[], preference: DuplicateTiePreferencesSvelte | undefined, ): AssetResponseDto | undefined { - if (!assets.length) { + if (assets.length === 0) { return; } let candidates = filterBySizeAndExif(assets); @@ -38,11 +33,11 @@ export function suggestBestDuplicate( } const filterBySizeAndExif = (assets: AssetResponseDto[]): AssetResponseDto[] => { - const maxSize = Math.max(...assets.map(sizeOf)); - const sizeFiltered = assets.filter((assets) => sizeOf(assets) === maxSize); + const maxSize = Math.max(...assets.map((asset) => sizeOf(asset))); + const sizeFilteredAssets = assets.filter((assets) => sizeOf(assets) === maxSize); - const maxExif = Math.max(...sizeFiltered.map(getExifCount)); - return sizeFiltered.filter((assets) => getExifCount(assets) === maxExif); + const maxExif = Math.max(...sizeFilteredAssets.map((asset) => getExifCount(asset))); + return sizeFilteredAssets.filter((assets) => getExifCount(assets) === maxExif); }; const filterBySource = (assets: AssetResponseDto[], priority: 'internal' | 'external'): AssetResponseDto[] => { diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3625b324c9..0bef930983 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -7,30 +7,30 @@ import { AppRoute } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; + import DuplicatesSettingsModal from '$lib/modals/DuplicatesSettingsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences-manager.svelte'; import { locale } from '$lib/stores/preferences.store'; import { stackAssets } from '$lib/utils/asset-utils'; + import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; import { handleError } from '$lib/utils/handle-error'; import type { AssetResponseDto } from '@immich/sdk'; import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk'; - import DuplicatesSettingsModal from '$lib/modals/DuplicatesSettingsModal.svelte'; import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui'; import { mdiCheckOutline, mdiChevronLeft, mdiChevronRight, + mdiCogOutline, mdiInformationOutline, mdiKeyboard, mdiPageFirst, mdiPageLast, mdiTrashCanOutline, - mdiCogOutline, } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import { duplicateTiePreference } from '$lib/stores/duplicate-tie-preferences.svelte'; - import { suggestBestDuplicate } from '$lib/utils/duplicate-utils'; interface Props { data: PageData; @@ -127,16 +127,12 @@ }; const handleDeduplicateAll = async () => { - const keepCandidates = duplicates.map((group) => suggestBestDuplicate(group.assets, duplicateTiePreference.value)); - - const idsToKeep: (string | undefined)[] = keepCandidates.map((assets) => assets?.id); + const idsToKeep = duplicates.map((group) => suggestBestDuplicate(group.assets, duplicateTiePreference.value)?.id); const idsToDelete = duplicates.flatMap((group, i) => - group.assets.map((asset) => asset.id).filter((id) => id !== idsToKeep[i]), + group.assets.map(({ id }) => id).filter((id) => id !== idsToKeep[i]), ); - const keptIds = idsToKeep.filter((id): id is string => id !== undefined); - let prompt, confirmText; if (featureFlagsManager.value.trash) { prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } }); @@ -151,7 +147,7 @@ await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !featureFlagsManager.value.trash } }); await updateAssets({ assetBulkUpdateDto: { - ids: [...idsToDelete, ...keptIds], + ids: [...idsToDelete, ...idsToKeep.filter((id) => id !== undefined)], duplicateId: null, }, });