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",
|
"day": "Day",
|
||||||
"days": "Days",
|
"days": "Days",
|
||||||
"deduplicate_all": "Deduplicate All",
|
"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_1": "Image size in bytes",
|
||||||
"deduplication_criteria_2": "Count of EXIF data",
|
"deduplication_criteria_2": "Count of EXIF data",
|
||||||
"deduplication_info": "Deduplication Info",
|
"deduplication_info": "Deduplication Info",
|
||||||
|
|
@ -860,6 +864,7 @@
|
||||||
"downloading_media": "Downloading media",
|
"downloading_media": "Downloading media",
|
||||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||||
"duplicates": "Duplicates",
|
"duplicates": "Duplicates",
|
||||||
|
"duplicates_settings": "Duplicates Settings",
|
||||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
|
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
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 { navigate } from '$lib/utils/navigation';
|
||||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { Button } from '@immich/ui';
|
import { Button } from '@immich/ui';
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { duplicateTiePreference } from '$lib/stores/duplicate-preferences';
|
||||||
interface Props {
|
interface Props {
|
||||||
assets: AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
|
onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
|
||||||
|
|
@ -24,19 +24,18 @@
|
||||||
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||||
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
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 selectedAssetIds = $state(new SvelteSet<string>());
|
||||||
let trashCount = $derived(assets.length - selectedAssetIds.size);
|
let trashCount = $derived(assets.length - selectedAssetIds.size);
|
||||||
|
|
||||||
|
const autoSelect = () => {
|
||||||
|
const suggested = suggestDuplicateWithPrefs(assets, $duplicateTiePreference) ?? assets[0];
|
||||||
|
selectedAssetIds = new SvelteSet([suggested.id]);
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const suggestedAsset = suggestDuplicate(assets);
|
autoSelect();
|
||||||
|
const unsub = duplicateTiePreference.subscribe(autoSelect);
|
||||||
if (!suggestedAsset) {
|
onDestroy(unsub);
|
||||||
selectedAssetIds = new SvelteSet(assets[0].id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedAssetIds.add(suggestedAsset.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
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 { getExifCount } from '$lib/utils/exif-utils';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { sortBy } from 'lodash-es';
|
import { sortBy } from 'lodash-es';
|
||||||
|
|
@ -13,11 +15,9 @@ import { sortBy } from 'lodash-es';
|
||||||
* @returns The best asset to keep
|
* @returns The best asset to keep
|
||||||
*/
|
*/
|
||||||
export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
|
export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
|
||||||
let duplicateAssets = sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte ?? 0);
|
let duplicateAssets = sortBy(assets, (assets) => assets.exifInfo?.fileSizeInByte ?? 0);
|
||||||
|
|
||||||
// Update the list to only include assets with the largest file size
|
|
||||||
duplicateAssets = duplicateAssets.filter(
|
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
|
// 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 the last asset in the list
|
||||||
return duplicateAssets.pop();
|
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 { locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { stackAssets } from '$lib/utils/asset-utils';
|
import { stackAssets } from '$lib/utils/asset-utils';
|
||||||
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
|
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
|
||||||
import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui';
|
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 {
|
import {
|
||||||
mdiCheckOutline,
|
mdiCheckOutline,
|
||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
|
|
@ -29,6 +31,7 @@
|
||||||
mdiPageFirst,
|
mdiPageFirst,
|
||||||
mdiPageLast,
|
mdiPageLast,
|
||||||
mdiTrashCanOutline,
|
mdiTrashCanOutline,
|
||||||
|
mdiCogOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
@ -43,6 +46,7 @@
|
||||||
general: ExplainedShortcut[];
|
general: ExplainedShortcut[];
|
||||||
actions: ExplainedShortcut[];
|
actions: ExplainedShortcut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExplainedShortcut {
|
interface ExplainedShortcut {
|
||||||
key: string[];
|
key: string[];
|
||||||
action: string;
|
action: string;
|
||||||
|
|
@ -129,11 +133,16 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeduplicateAll = async () => {
|
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) =>
|
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;
|
let prompt, confirmText;
|
||||||
if ($featureFlags.trash) {
|
if ($featureFlags.trash) {
|
||||||
prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } });
|
prompt = $t('bulk_trash_duplicates_confirmation', { values: { count: idsToDelete.length } });
|
||||||
|
|
@ -148,7 +157,7 @@
|
||||||
await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !$featureFlags.trash } });
|
await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !$featureFlags.trash } });
|
||||||
await updateAssets({
|
await updateAssets({
|
||||||
assetBulkUpdateDto: {
|
assetBulkUpdateDto: {
|
||||||
ids: [...idsToDelete, ...idsToKeep.filter((id): id is string => !!id)],
|
ids: [...idsToDelete, ...keptIds],
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -225,6 +234,22 @@
|
||||||
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
|
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
|
||||||
{#snippet buttons()}
|
{#snippet buttons()}
|
||||||
<HStack gap={0}>
|
<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
|
<Button
|
||||||
leadingIcon={mdiTrashCanOutline}
|
leadingIcon={mdiTrashCanOutline}
|
||||||
onclick={() => handleDeduplicateAll()}
|
onclick={() => handleDeduplicateAll()}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue