Toni 2025-12-10 18:13:11 +07:00 committed by GitHub
commit 0628be7a98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 161 additions and 4 deletions

@ -2047,6 +2047,18 @@
"sync_status": "Sync Status",
"sync_status_subtitle": "View and manage the sync system",
"sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich",
"synchronize_albums": "Synchronize Albums",
"synchronize_albums_description": "Add the resolved asset to every album that any of the duplicates belong to.",
"synchronize_description": "Synchronize Description",
"synchronize_description_description": "Merge the descriptions from all duplicates into a single description for the resolved asset.",
"synchronize_favorites": "Synchronize Favorites",
"synchronize_favorites_description": "If any duplicate is marked as a favorite, mark the resolved asset as favorite.",
"synchronize_location": "Synchronize Location",
"synchronize_location_description": "If exactly one unique latitude/longitude pair exists across the duplicates, apply that location to the resolved asset.",
"synchronize_rating": "Synchronize Rating",
"synchronize_rating_description": "Use the highest rating found in the duplicates' EXIF data for the resolved asset.",
"synchronize_visibility": "Synchronize Visibility setting",
"synchronize_visibility_description": "Apply the most restrictive visibility present among the duplicates (Locked → Hidden → Archive → Timeline).",
"tag": "Tag",
"tag_assets": "Tag assets",
"tag_created": "Created tag: {tag}",

@ -0,0 +1,51 @@
<script lang="ts">
import type { DuplicateSettings } from '$lib/stores/preferences.store';
import { Button, Field, HStack, Modal, ModalBody, ModalFooter, Stack, Switch } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
settings: DuplicateSettings;
onClose: (settings?: DuplicateSettings) => void;
}
let { settings: initialValues, onClose }: Props = $props();
let settings = $state(initialValues);
const onsubmit = (event: Event) => {
event.preventDefault();
onClose(settings);
};
</script>
<Modal title={$t('options')} {onClose} size="medium">
<ModalBody>
<form {onsubmit} id="duplicate-settings-form">
<Stack gap={4}>
<Field label={$t('synchronize_albums')} description={$t('synchronize_albums_description')}>
<Switch bind:checked={settings.synchronizeAlbums} />
</Field>
<Field label={$t('synchronize_favorites')} description={$t('synchronize_favorites_description')}>
<Switch bind:checked={settings.synchronizeFavorites} />
</Field>
<Field label={$t('synchronize_rating')} description={$t('synchronize_rating_description')}>
<Switch bind:checked={settings.synchronizeRating} />
</Field>
<Field label={$t('synchronize_description')} description={$t('synchronize_description_description')}>
<Switch bind:checked={settings.synchronizeDescpription} />
</Field>
<Field label={$t('synchronize_visibility')} description={$t('synchronize_visibility_description')}>
<Switch bind:checked={settings.synchronizeVisibility} />
</Field>
<Field label={$t('synchronize_location')} description={$t('synchronize_location_description')}>
<Switch bind:checked={settings.synchronizeLocation} />
</Field>
</Stack>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button type="submit" shape="round" fullWidth form="duplicate-settings-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>

@ -151,3 +151,21 @@ export const autoPlayVideo = persisted<boolean>('auto-play-video', true, {});
export const alwaysLoadOriginalVideo = persisted<boolean>('always-load-original-video', false, {});
export const recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {});
export interface DuplicateSettings {
synchronizeAlbums: boolean;
synchronizeVisibility: boolean;
synchronizeFavorites: boolean;
synchronizeRating: boolean;
synchronizeDescpription: boolean;
synchronizeLocation: boolean;
}
export const duplicateSettings = persistedObject<DuplicateSettings>('duplicate-settings', {
synchronizeAlbums: false,
synchronizeVisibility: false,
synchronizeFavorites: false,
synchronizeRating: false,
synchronizeDescpription: false,
synchronizeLocation: false,
});

@ -3,23 +3,26 @@
import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import DuplicateSettingsModal from '$lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { duplicateSettings, locale } from '$lib/stores/preferences.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 type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk';
import { AssetVisibility, copyAsset, deleteAssets, deleteDuplicates, getAssetInfo, updateAssets } from '@immich/sdk';
import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui';
import {
mdiCheckOutline,
mdiChevronLeft,
mdiChevronRight,
mdiCogOutline,
mdiInformationOutline,
mdiKeyboard,
mdiPageFirst,
@ -56,6 +59,13 @@
],
};
const onShowSettings = async () => {
const settings = await modalManager.show(DuplicateSettingsModal, { settings: { ...$duplicateSettings } });
if (settings) {
$duplicateSettings = settings;
}
};
let duplicates = $state(data.duplicates);
const { isViewing: showAssetViewer } = assetViewingStore;
@ -98,11 +108,68 @@
toastManager.success(message);
};
const getSyncedInfo = async (assetIds: string[]) => {
const allAssetsInfo = await Promise.all(
assetIds.map((assetId) => getAssetInfo({ ...authManager.params, id: assetId })),
);
// If any of the assets is favorite, we consider the synced info as favorite
const isFavorite = allAssetsInfo.some((asset) => asset.isFavorite);
// Choose the most restrictive visibility level among the assets
const visibility = [
AssetVisibility.Locked,
AssetVisibility.Hidden,
AssetVisibility.Archive,
AssetVisibility.Timeline,
].find((level) => allAssetsInfo.some((asset) => asset.visibility === level));
// Choose the highest rating from the exif data of the assets
const rating = Math.max(...allAssetsInfo.map((asset) => asset.exifInfo?.rating ?? 0));
// Concatenate the single descriptions of the assets
const description = allAssetsInfo.map((asset) => asset.exifInfo?.description).join('\n');
// Check that only one pair of latitude/longitude exists among the assets
const latitudes = new Set(allAssetsInfo.map((asset) => asset.exifInfo?.latitude).filter((lat) => lat !== null));
const longitudes = new Set(allAssetsInfo.map((asset) => asset.exifInfo?.longitude).filter((lon) => lon !== null));
const latitude = latitudes.size === 1 ? Array.from(latitudes)[0] : null;
const longitude = longitudes.size === 1 ? Array.from(longitudes)[0] : null;
return { isFavorite, visibility, rating, description, latitude, longitude };
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
return withConfirmation(
async () => {
const { isFavorite, visibility, rating, description, latitude, longitude } =
await getSyncedInfo(duplicateAssetIds);
let assetBulkUpdate: AssetBulkUpdateDto = {
ids: duplicateAssetIds,
duplicateId: null,
};
if ($duplicateSettings.synchronizeFavorites) {
assetBulkUpdate.isFavorite = isFavorite;
}
if ($duplicateSettings.synchronizeVisibility) {
assetBulkUpdate.visibility = visibility;
}
if ($duplicateSettings.synchronizeRating) {
assetBulkUpdate.rating = rating;
}
if ($duplicateSettings.synchronizeDescpription) {
assetBulkUpdate.description = description;
}
if ($duplicateSettings.synchronizeLocation && latitude !== null && longitude !== null) {
assetBulkUpdate.latitude = latitude;
assetBulkUpdate.longitude = longitude;
}
if ($duplicateSettings.synchronizeAlbums) {
const idsToKeep = duplicateAssetIds.filter((id) => !trashIds.includes(id));
for (const sourceId of trashIds) {
for (const targetId of idsToKeep) {
await copyAsset({ assetCopyDto: { sourceId, targetId, albums: true } });
}
}
}
await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !featureFlagsManager.value.trash } });
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
await updateAssets({ assetBulkUpdateDto: assetBulkUpdate });
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
@ -245,6 +312,15 @@
onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
aria-label={$t('show_keyboard_shortcuts')}
/>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiCogOutline}
title={$t('settings')}
onclick={onShowSettings}
aria-label={$t('settings')}
/>
</HStack>
{/snippet}