feat: Selectable metadata in duplicates utility with diffing (#26328)

pull/28482/head
Oliver Roed Schøler 2026-05-18 17:49:51 +02:00 committed by GitHub
parent 3d075f2bf8
commit 0544d22902
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 383 additions and 157 deletions

View File

@ -897,6 +897,7 @@
"date_of_birth": "Date of birth",
"date_of_birth_saved": "Date of birth saved successfully",
"date_range": "Date range",
"date_time_original": "Date/Time Original",
"day": "Day",
"days": "Days",
"deduplicate_all": "Deduplicate All",
@ -1197,11 +1198,13 @@
"export_as_json": "Export as JSON",
"export_database": "Export Database",
"export_database_description": "Export the SQLite database",
"exposure_time": "Exposure Time",
"extension": "Extension",
"external": "External",
"external_libraries": "External Libraries",
"external_network": "External network",
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"f_number": "F-Number",
"face_unassigned": "Unassigned",
"failed": "Failed",
"failed_count": "Failed: {count}",
@ -1219,7 +1222,6 @@
"features_setting_description": "Manage the app features",
"file_name_or_extension": "File name or extension",
"file_name_text": "File name",
"file_name_with_value": "File name: {file_name}",
"file_size": "File size",
"filename": "Filename",
"filetype": "Filetype",
@ -1232,6 +1234,7 @@
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
"focal_length": "Focal Length",
"folder": "Folder",
"folder_not_found": "Folder not found",
"folders": "Folders",
@ -1352,6 +1355,7 @@
"ios_debug_info_no_sync_yet": "No background sync job has run yet",
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"iso": "ISO",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
@ -1584,6 +1588,7 @@
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
"model": "Model",
"modify_date": "Modify Date",
"month": "Month",
"more": "More",
"motion": "Motion",
@ -1706,6 +1711,7 @@
"organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library",
"orientation": "Orientation",
"original": "original",
"other": "Other",
"other_devices": "Other devices",
@ -1820,6 +1826,7 @@
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
"profile_image_of_user": "Profile image of {user}",
"profile_picture_set": "Profile picture set.",
"projection_type": "Projection Type",
"public_album": "Public album",
"public_share": "Public Share",
"purchase_account_info": "Supporter",
@ -2189,7 +2196,9 @@
"show_in_timeline": "Show in timeline",
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
"show_keyboard_shortcuts": "Show keyboard shortcuts",
"show_less": "Show less",
"show_metadata": "Show metadata",
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
"show_or_hide_info": "Show or hide info",
"show_password": "Show password",
"show_person_options": "Show person options",

View File

@ -0,0 +1,301 @@
import type { AssetResponseDto } from '@immich/sdk';
import {
mdiBrightness6,
mdiCalendar,
mdiCamera,
mdiCameraIris,
mdiCameraOutline,
mdiClockEditOutline,
mdiCrosshairsGps,
mdiEarth,
mdiFileClockOutline,
mdiFileEditOutline,
mdiFileImageOutline,
mdiFitToScreen,
mdiFolderOutline,
mdiMapMarkerOutline,
mdiPanorama,
mdiPhoneRotateLandscape,
mdiRayStartArrow,
mdiStarOutline,
mdiTextBox,
mdiTimerOutline,
mdiWeightKilogram,
} from '@mdi/js';
import { DateTime } from 'luxon';
import type { MessageFormatter } from 'svelte-i18n';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
const truncateMiddle = (path: string, maxLength = 50): string => {
if (path.length <= maxLength) {
return path;
}
const lastSlash = path.lastIndexOf('/');
const tail = lastSlash === -1 ? path : path.slice(lastSlash);
if (tail.length >= maxLength - 3) {
const half = Math.floor((maxLength - 3) / 2);
return path.slice(0, half) + '...' + path.slice(-half);
}
const headLength = maxLength - 3 - tail.length;
return path.slice(0, headLength) + '...' + tail;
};
const formatISODateToLocale = (iso: string, locale: string | undefined): string =>
fromISODateTimeUTC(iso).toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale });
const getDateTime = (asset: AssetResponseDto) => {
const timeZone = asset.exifInfo?.timeZone;
return timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime);
};
type MetadataFieldDefinition = {
icon: string;
titleKey: string;
keys: readonly string[];
render: (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) => string;
};
const metadataFields = [
{
icon: mdiFileImageOutline,
titleKey: 'file_name_text',
keys: ['originalFileName'],
render: (asset, $t) => asset.originalFileName || $t('unknown'),
},
{
icon: mdiFolderOutline,
titleKey: 'path',
keys: ['originalPath'],
render: (asset, $t) => truncateMiddle(asset.originalPath) || $t('unknown'),
},
{
icon: mdiWeightKilogram,
titleKey: 'file_size',
keys: ['fileSize'],
render: (asset) => getFileSize(asset),
},
{
icon: mdiFitToScreen,
titleKey: 'resolution',
keys: ['resolution'],
render: (asset, $t) => getAssetResolution(asset) || $t('unknown'),
},
{
icon: mdiFileClockOutline,
titleKey: 'created_at',
keys: ['fileCreatedAt'],
render: (asset, _t, locale) => formatISODateToLocale(asset.fileCreatedAt, locale),
},
{
icon: mdiFileEditOutline,
titleKey: 'updated_at',
keys: ['fileModifiedAt'],
render: (asset, _t, locale) => formatISODateToLocale(asset.fileModifiedAt, locale),
},
{
icon: mdiCalendar,
titleKey: 'date_time_original',
keys: ['dateTimeOriginal'],
render: (asset, $t, locale) => {
const dateTime = getDateTime(asset);
return dateTime
? dateTime.toLocaleString(
{
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'shortOffset',
},
{ locale },
)
: $t('unknown');
},
},
{
icon: mdiEarth,
titleKey: 'timezone',
keys: ['timeZone'],
render: (asset, $t) => getDateTime(asset)?.offsetNameShort ?? $t('unknown'),
},
{
icon: mdiClockEditOutline,
titleKey: 'modify_date',
keys: ['modifyDate'],
render: (asset, $t, locale) =>
asset.exifInfo?.modifyDate ? formatISODateToLocale(asset.exifInfo.modifyDate, locale) : $t('unknown'),
},
{
icon: mdiMapMarkerOutline,
titleKey: 'location',
keys: ['city', 'state', 'country'],
render: (asset, $t) => {
const parts = [asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : $t('unknown');
},
},
{
icon: mdiCrosshairsGps,
titleKey: 'gps',
keys: ['latitude', 'longitude'],
render: (asset, $t) =>
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null
? `${asset.exifInfo.latitude.toFixed(4)}, ${asset.exifInfo.longitude.toFixed(4)}`
: $t('unknown'),
},
{
icon: mdiCameraOutline,
titleKey: 'make',
keys: ['make'],
render: (asset, $t) => asset.exifInfo?.make || $t('unknown'),
},
{
icon: mdiCamera,
titleKey: 'model',
keys: ['model'],
render: (asset, $t) => asset.exifInfo?.model || $t('unknown'),
},
{
icon: mdiCameraIris,
titleKey: 'lens_model',
keys: ['lensModel'],
render: (asset, $t) => asset.exifInfo?.lensModel || $t('unknown'),
},
{
icon: mdiCameraIris,
titleKey: 'f_number',
keys: ['fNumber'],
render: (asset, $t) => (asset.exifInfo?.fNumber == null ? $t('unknown') : `f/${asset.exifInfo.fNumber.toFixed(1)}`),
},
{
icon: mdiRayStartArrow,
titleKey: 'focal_length',
keys: ['focalLength'],
render: (asset, $t) => (asset.exifInfo?.focalLength == null ? $t('unknown') : `${asset.exifInfo.focalLength} mm`),
},
{
icon: mdiBrightness6,
titleKey: 'iso',
keys: ['iso'],
render: (asset, $t) => (asset.exifInfo?.iso == null ? $t('unknown') : `ISO ${asset.exifInfo.iso}`),
},
{
icon: mdiTimerOutline,
titleKey: 'exposure_time',
keys: ['exposureTime'],
render: (asset, $t) => asset.exifInfo?.exposureTime || $t('unknown'),
},
{
icon: mdiTextBox,
titleKey: 'description',
keys: ['description'],
render: (asset, $t) => asset.exifInfo?.description || $t('unknown'),
},
{
icon: mdiStarOutline,
titleKey: 'rating',
keys: ['rating'],
render: (asset, $t) => (asset.exifInfo?.rating == null ? $t('unknown') : `${asset.exifInfo.rating} stars`),
},
{
icon: mdiPhoneRotateLandscape,
titleKey: 'orientation',
keys: ['orientation'],
render: (asset, $t) => String(asset.exifInfo?.orientation || $t('unknown')),
},
{
icon: mdiPanorama,
titleKey: 'projection_type',
keys: ['projectionType'],
render: (asset, $t) => asset.exifInfo?.projectionType || $t('unknown'),
},
] as const satisfies readonly MetadataFieldDefinition[];
export type MetadataFieldKey = (typeof metadataFields)[number]['keys'][number];
export type DifferingMetadataFields = Partial<Record<MetadataFieldKey, boolean>>;
export const metadataKeys: readonly MetadataFieldKey[] = metadataFields.flatMap(({ keys }) => keys);
export const countDifferingMetadataItems = (differing: DifferingMetadataFields): number =>
metadataFields.filter(({ keys }) => keys.some((k) => differing[k as MetadataFieldKey])).length;
export const getAllMetadataItems = (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) =>
metadataFields.map(({ icon, titleKey, keys, render }) => ({
icon,
title: $t(titleKey),
render: render(asset, $t, locale),
keys,
}));
const normalizeForComparison = (key: MetadataFieldKey, value: unknown): unknown => {
if (value === null || value === undefined) {
return value;
}
if (key === 'fileCreatedAt' || key === 'fileModifiedAt' || key === 'dateTimeOriginal' || key === 'modifyDate') {
const dateTime = DateTime.fromISO(String(value));
return dateTime.isValid ? dateTime.toISO() : String(value);
}
if (key === 'fNumber' && typeof value === 'number') {
return Number(value.toFixed(1));
}
if ((key === 'latitude' || key === 'longitude') && typeof value === 'number') {
return Number(value.toFixed(4));
}
if (key === 'focalLength' && typeof value === 'number') {
return Number(value.toFixed(2));
}
return value;
};
const getValueForAsset = (asset: AssetResponseDto, key: MetadataFieldKey): unknown => {
switch (key) {
case 'fileCreatedAt':
case 'fileModifiedAt':
case 'originalFileName':
case 'originalPath': {
return asset[key];
}
case 'fileSize': {
return getFileSize(asset);
}
case 'resolution': {
return getAssetResolution(asset);
}
default: {
if (asset.exifInfo && key in asset.exifInfo) {
return asset.exifInfo[key as keyof typeof asset.exifInfo];
}
return undefined;
}
}
};
export const computeDifferingMetadataFields = (assets: AssetResponseDto[]): DifferingMetadataFields => {
const diffs: DifferingMetadataFields = {};
for (const key of metadataKeys) {
const uniqueValues = new Set<unknown>();
for (const asset of assets) {
const value = getValueForAsset(asset, key);
if (value !== undefined && value !== null) {
uniqueValues.add(normalizeForComparison(key, value));
}
}
diffs[key] = uniqueValues.size > 1;
}
return diffs;
};

View File

@ -1,103 +1,42 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import {
mdiBookmarkOutline,
mdiCalendar,
mdiClock,
mdiFile,
mdiFitToScreen,
mdiFolderOutline,
mdiHeart,
mdiImageMultipleOutline,
mdiImageOutline,
mdiMagnifyPlus,
mdiMapMarkerOutline,
} from '@mdi/js';
import { mdiBookmarkOutline, mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import InfoRow from './InfoRow.svelte';
interface Props {
assets: AssetResponseDto[];
asset: AssetResponseDto;
isSelected: boolean;
onSelectAsset: (asset: AssetResponseDto) => void;
onViewAsset: (asset: AssetResponseDto) => void;
differingMetadataFields: DifferingMetadataFields;
showMore?: boolean;
initialVisibleCount?: number;
}
let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
let {
asset,
isSelected,
onSelectAsset,
onViewAsset,
differingMetadataFields,
showMore = false,
initialVisibleCount = 5,
}: Props = $props();
let isFromExternalLibrary = $derived(!!asset.libraryId);
let locationParts = $derived([asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean));
let timeZone = $derived(asset.exifInfo?.timeZone);
let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
const visibleMetadataItems = $derived(
getAllMetadataItems(asset, $t, $locale)
.filter(({ keys }) => keys.some((k) => differingMetadataFields[k]))
.slice(0, showMore ? undefined : initialVisibleCount),
);
const isDifferent = (getter: (asset: AssetResponseDto) => string | undefined): boolean => {
return new Set(assets.map((asset) => getter(asset))).size > 1;
};
const hasDifferentValues = $derived({
fileName: isDifferent((a) => a.originalFileName),
fileSize: isDifferent((a) => getFileSize(a)),
resolution: isDifferent((a) => getAssetResolution(a)),
originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')),
date: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
tz && a.exifInfo?.dateTimeOriginal
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
: fromISODateTimeUTC(a.localDateTime);
return dt?.toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale: $locale });
}),
time: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
tz && a.exifInfo?.dateTimeOriginal
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
: fromISODateTimeUTC(a.localDateTime);
return dt?.toLocaleString(
{
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: tz ? 'shortOffset' : undefined,
},
{ locale: $locale },
);
}),
location: isDifferent(
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
),
});
const getBasePath = (fullpath: string, fileName: string): string => {
if (fileName && fullpath.endsWith(fileName)) {
return fullpath.slice(0, -(fileName.length + 1));
}
return fullpath;
};
function truncateMiddle(path: string, maxLength: number = 50): string {
if (path.length <= maxLength) {
return path;
}
const start = Math.floor(maxLength / 2) - 2;
const end = Math.floor(maxLength / 2) - 2;
return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end));
}
</script>
<div class="min-w-60 flex-1 rounded-lg border transition-colors">
@ -166,69 +105,13 @@
? 'bg-success/15 dark:bg-[#001a06]'
: 'bg-transparent'}"
>
<InfoRow
icon={mdiImageOutline}
highlight={hasDifferentValues.fileName}
title={$t('file_name_with_value', { values: { file_name: asset.originalFileName ?? '' } })}
>
{asset.originalFileName}
</InfoRow>
<InfoRow
icon={mdiFolderOutline}
highlight={hasDifferentValues.originalPath}
title={$t('full_path', { values: { path: asset.originalPath } })}
>
{truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')}
</InfoRow>
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
{getFileSize(asset)}
</InfoRow>
<InfoRow icon={mdiFitToScreen} highlight={hasDifferentValues.resolution} title={$t('resolution')}>
{getAssetResolution(asset)}
</InfoRow>
<InfoRow icon={mdiCalendar} highlight={hasDifferentValues.date} title={$t('date')}>
{#if dateTime}
{dateTime.toLocaleString(
{
month: 'short',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
<InfoRow icon={mdiClock} highlight={hasDifferentValues.time} title={$t('time')}>
{#if dateTime}
{dateTime.toLocaleString(
{
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: timeZone ? 'shortOffset' : undefined,
},
{ locale: $locale },
)}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
<InfoRow icon={mdiMapMarkerOutline} highlight={hasDifferentValues.location} title={$t('location')}>
{#if locationParts.length > 0}
{locationParts.join(', ')}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
{#each visibleMetadataItems as { icon, title, render, keys } (keys[0])}
<InfoRow {icon} {title}>
{render}
</InfoRow>
{/each}
<!-- Albums always shown -->
<InfoRow icon={mdiBookmarkOutline} borderBottom={false} title={$t('albums')}>
{#await getAllAlbums({ assetId: asset.id })}
{$t('scanning_for_album')}

View File

@ -6,10 +6,15 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import {
computeDifferingMetadataFields,
countDifferingMetadataItems,
type DifferingMetadataFields,
} 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 { Button, Icon } from '@immich/ui';
import { mdiCheck, mdiChevronDown, mdiChevronUp, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
@ -26,6 +31,13 @@
let selectedAssetIds = $state(new SvelteSet<string>());
let trashCount = $derived(assets.length - selectedAssetIds.size);
const InitialVisibleCount = 5;
const differingMetadataFields: DifferingMetadataFields = $derived(computeDifferingMetadataFields(assets));
const differingCount = $derived(countDifferingMetadataItems(differingMetadataFields));
const hasMore = $derived(differingCount > InitialVisibleCount);
let showMore = $state(false);
onMount(() => {
if (suggestedKeepAssetIds.length > 0) {
for (const id of suggestedKeepAssetIds) {
@ -160,10 +172,29 @@
<div class="overflow-x-auto p-2">
<div class="mx-auto flex w-fit min-w-full flex-nowrap place-items-start justify-center gap-1">
{#each assets as asset (asset.id)}
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
<DuplicateAsset
{asset}
{onSelectAsset}
isSelected={selectedAssetIds.has(asset.id)}
{onViewAsset}
{differingMetadataFields}
{showMore}
initialVisibleCount={InitialVisibleCount}
/>
{/each}
</div>
</div>
{#if hasMore}
<div class="flex justify-center pb-2">
<Button size="small" variant="ghost" color="secondary" onclick={() => (showMore = !showMore)}>
<Icon icon={showMore ? mdiChevronUp : mdiChevronDown} size="18" class="me-1" />
{showMore
? $t('show_less')
: $t('show_more_fields', { values: { count: differingCount - InitialVisibleCount } })}
</Button>
</div>
{/if}
</div>
{#if assetViewerManager.isViewing}

View File

@ -6,21 +6,23 @@
icon: string;
children?: Snippet;
borderBottom?: boolean;
highlight?: boolean;
title?: string;
}
let { icon, children, borderBottom = true, highlight = false, title }: Props = $props();
let { icon, children, borderBottom = true, title }: Props = $props();
</script>
<div class="grid w-full grid-cols-[25px_1fr] px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
<div class="w-full justify-self-end overflow-hidden rounded-sm px-1 text-end transition-colors">
<Text
size="tiny"
fontWeight={highlight ? 'semi-bold' : 'normal'}
class={`${highlight ? 'text-primary' : ''} w-full overflow-hidden text-ellipsis`}
>
<div class="grid w-full grid-cols-[20px_auto_1fr] overflow-hidden px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="16" class="self-center text-dark/25" />
{#if title}
<Text size="tiny" class="self-center truncate px-1 pr-2 text-immich-fg/40 dark:text-immich-dark-fg/40">
{title}
</Text>
{/if}
<div class="justify-self-end overflow-hidden rounded-sm px-1 text-end transition-colors">
<Text size="tiny" class="break-all">
{@render children?.()}
</Text>
</div>