diff --git a/i18n/en.json b/i18n/en.json index d265c9b9d8..cbed0136ed 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -782,6 +782,10 @@ "day": "Day", "days": "Days", "deduplicate_all": "Deduplicate All", + "deduplicate_prefer_default": "Default", + "deduplicate_prefer_external": "Prefer external", + "deduplicate_prefer_internal": "Prefer internal", + "deduplicate_source_preference": "Source preference", "deduplication_criteria_1": "Image size in bytes", "deduplication_criteria_2": "Count of EXIF data", "deduplication_info": "Deduplication Info", @@ -860,6 +864,7 @@ "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", "duration": "Duration", "edit": "Edit", 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 5ec0837423..ae41e1e223 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,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; - import { suggestDuplicate } from '$lib/utils/duplicate-utils'; + import { suggestDuplicateWithPrefs } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; @@ -13,7 +13,7 @@ import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; - + import { duplicateTiePreference } from '$lib/stores/duplicate-preferences'; interface Props { assets: AssetResponseDto[]; onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; @@ -24,19 +24,18 @@ const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id); - // eslint-disable-next-line svelte/no-unnecessary-state-wrap let selectedAssetIds = $state(new SvelteSet()); let trashCount = $derived(assets.length - selectedAssetIds.size); + const autoSelect = () => { + const suggested = suggestDuplicateWithPrefs(assets, $duplicateTiePreference) ?? assets[0]; + selectedAssetIds = new SvelteSet([suggested.id]); + }; + onMount(() => { - const suggestedAsset = suggestDuplicate(assets); - - if (!suggestedAsset) { - selectedAssetIds = new SvelteSet(assets[0].id); - return; - } - - selectedAssetIds.add(suggestedAsset.id); + autoSelect(); + const unsub = duplicateTiePreference.subscribe(autoSelect); + onDestroy(unsub); }); onDestroy(() => { diff --git a/web/src/lib/modals/DuplicatesSettingsModal.svelte b/web/src/lib/modals/DuplicatesSettingsModal.svelte new file mode 100644 index 0000000000..97803c95b4 --- /dev/null +++ b/web/src/lib/modals/DuplicatesSettingsModal.svelte @@ -0,0 +1,90 @@ + + + + +
+
+

{$t('deduplicate_source_preference')}

+ +
+ + + +
+
+ +
+ + +
+ + +
+
+
+
+
diff --git a/web/src/lib/stores/duplicate-preferences.ts b/web/src/lib/stores/duplicate-preferences.ts new file mode 100644 index 0000000000..23cf47a9b8 --- /dev/null +++ b/web/src/lib/stores/duplicate-preferences.ts @@ -0,0 +1,5 @@ +import { writable } from 'svelte/store'; + +export type TiePreference = 'default' | 'external' | 'internal'; + +export const duplicateTiePreference = writable('default'); diff --git a/web/src/lib/utils/duplicate-selection.ts b/web/src/lib/utils/duplicate-selection.ts new file mode 100644 index 0000000000..b9dd190e2a --- /dev/null +++ b/web/src/lib/utils/duplicate-selection.ts @@ -0,0 +1,66 @@ +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.ts b/web/src/lib/utils/duplicate-utils.ts index 1c783a3667..09220a30ec 100644 --- a/web/src/lib/utils/duplicate-utils.ts +++ b/web/src/lib/utils/duplicate-utils.ts @@ -1,3 +1,5 @@ +import type { TiePreference } from '$lib/stores/duplicate-preferences'; +import { applyLibraryTieBreaker, selectDefaultByCurrentHeuristic } from '$lib/utils/duplicate-selection'; import { getExifCount } from '$lib/utils/exif-utils'; import type { AssetResponseDto } from '@immich/sdk'; import { sortBy } from 'lodash-es'; @@ -13,11 +15,9 @@ import { sortBy } from 'lodash-es'; * @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 + let duplicateAssets = sortBy(assets, (assets) => assets.exifInfo?.fileSizeInByte ?? 0); duplicateAssets = duplicateAssets.filter( - (asset) => asset.exifInfo?.fileSizeInByte === duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte, + (assets) => assets.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 @@ -28,3 +28,26 @@ export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | // 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)); +}; 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 adc0f679cb..c0d105b38c 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 @@ -15,11 +15,13 @@ import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { stackAssets } from '$lib/utils/asset-utils'; - import { suggestDuplicate } 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 { 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, @@ -29,6 +31,7 @@ mdiPageFirst, mdiPageLast, mdiTrashCanOutline, + mdiCogOutline, } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -43,6 +46,7 @@ general: ExplainedShortcut[]; actions: ExplainedShortcut[]; } + interface ExplainedShortcut { key: string[]; action: string; @@ -129,11 +133,16 @@ }; const handleDeduplicateAll = async () => { - const idsToKeep = duplicates.map((group) => suggestDuplicate(group.assets)).map((asset) => asset?.id); + const keepCandidates = duplicates.map((group) => suggestDuplicateWithPrefs(group.assets, $duplicateTiePreference)); + + const idsToKeep: (string | undefined)[] = keepCandidates.map((assets) => assets?.id); + const idsToDelete = duplicates.flatMap((group, i) => - group.assets.map((asset) => asset.id).filter((asset) => asset !== idsToKeep[i]), + group.assets.map((assets) => assets.id).filter((id) => id !== idsToKeep[i]), ); + const keptIds = idsToKeep.filter((id): id is string => id !== undefined); + let prompt, confirmText; if ($featureFlags.trash) { prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } }); @@ -148,7 +157,7 @@ await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !$featureFlags.trash } }); await updateAssets({ assetBulkUpdateDto: { - ids: [...idsToDelete, ...idsToKeep.filter((id): id is string => !!id)], + ids: [...idsToDelete, ...keptIds], duplicateId: null, }, }); @@ -225,6 +234,22 @@ {#snippet buttons()} + +