mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge 90da972d3a into e7d6a066f8
This commit is contained in:
commit
aeb3d7f488
7 changed files with 232 additions and 19 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
90
web/src/lib/modals/DuplicatesSettingsModal.svelte
Normal file
90
web/src/lib/modals/DuplicatesSettingsModal.svelte
Normal 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>
|
||||
5
web/src/lib/stores/duplicate-preferences.ts
Normal file
5
web/src/lib/stores/duplicate-preferences.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export type TiePreference = 'default' | 'external' | 'internal';
|
||||
|
||||
export const duplicateTiePreference = writable<TiePreference>('default');
|
||||
66
web/src/lib/utils/duplicate-selection.ts
Normal file
66
web/src/lib/utils/duplicate-selection.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue