Files
immich/web/src/lib/utils/duplicate-utils.ts
T

302 lines
8.8 KiB
TypeScript

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