This commit is contained in:
Joe Küng 2025-10-17 11:16:22 -05:00 committed by GitHub
commit aeb3d7f488
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 232 additions and 19 deletions

View file

@ -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",

View file

@ -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<string>());
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(() => {

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { Modal, ModalBody, Button, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import { duplicateTiePreference, type TiePreference } from '$lib/stores/duplicate-preferences';
import { get } from 'svelte/store';
import { mdiCogOutline } from '@mdi/js';
const initialTie = get(duplicateTiePreference) as TiePreference;
let tiePreferenceLocal = $state<TiePreference>(initialTie);
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
const cancel = () => onClose();
const confirm = () => {
duplicateTiePreference.set(tiePreferenceLocal);
onClose();
};
const resetToDefault = () => {
tiePreferenceLocal = 'default';
};
</script>
<Modal title={$t('duplicates_settings')} {onClose} icon={mdiCogOutline}>
<ModalBody>
<div class="space-y-3">
<section class="rounded-2xl border dark:border-2 border-gray-300 dark:border-gray-700 p-4">
<h4 class="text-sm font-semibold mb-3">{$t('deduplicate_source_preference')}</h4>
<div
class="inline-flex rounded-full overflow-hidden border border-gray-700"
role="group"
aria-label={$t('duplicates_settings')}
>
<Button
size="small"
variant="ghost"
class="rounded-none"
color={tiePreferenceLocal === 'external' ? 'primary' : 'secondary'}
aria-pressed={tiePreferenceLocal === 'external'}
title={$t('deduplicate_prefer_external')}
onclick={() => (tiePreferenceLocal = 'external')}
>
<Text class="hidden md:block">{$t('deduplicate_prefer_external')}</Text>
</Button>
<Button
size="small"
variant="ghost"
class="rounded-none"
color={tiePreferenceLocal === 'default' ? 'primary' : 'secondary'}
aria-pressed={tiePreferenceLocal === 'default'}
title={$t('deduplicate_prefer_default')}
onclick={() => (tiePreferenceLocal = 'default')}
>
<Text class="hidden md:block">{$t('deduplicate_prefer_default')}</Text>
</Button>
<Button
size="small"
variant="ghost"
class="rounded-none"
color={tiePreferenceLocal === 'internal' ? 'primary' : 'secondary'}
aria-pressed={tiePreferenceLocal === 'internal'}
title={$t('deduplicate_prefer_internal')}
onclick={() => (tiePreferenceLocal = 'internal')}
>
<Text class="hidden md:block">{$t('deduplicate_prefer_internal')}</Text>
</Button>
</div>
</section>
<div class="flex items-center justify-between gap-2 pt-2">
<Button size="small" variant="ghost" color="secondary" onclick={resetToDefault}>
<Text>{$t('reset_to_default')}</Text>
</Button>
<div class="flex items-center gap-2">
<Button size="small" variant="ghost" color="secondary" onclick={cancel}>
<Text>{$t('cancel')}</Text>
</Button>
<Button size="small" color="primary" onclick={confirm}>
<Text>{$t('confirm')}</Text>
</Button>
</div>
</div>
</div>
</ModalBody>
</Modal>

View file

@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export type TiePreference = 'default' | 'external' | 'internal';
export const duplicateTiePreference = writable<TiePreference>('default');

View file

@ -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;
}

View file

@ -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));
};

View file

@ -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 @@
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
{#snippet buttons()}
<HStack gap={0}>
<Button
size="small"
variant="ghost"
color={$duplicateTiePreference === 'default' ? 'secondary' : 'primary'}
leadingIcon={mdiCogOutline}
onclick={() => modalManager.show(DuplicatesSettingsModal)}
title={$t('settings')}
aria-label={$t('settings')}
class="relative"
>
<Text class="hidden md:block">{$t('settings')}</Text>
{#if $duplicateTiePreference !== 'default'}
<span class="ml-2 inline-block h-2 w-2 rounded-full bg-primary" aria-hidden="true"></span>
{/if}
</Button>
<Button
leadingIcon={mdiTrashCanOutline}
onclick={() => handleDeduplicateAll()}