diff --git a/i18n/en.json b/i18n/en.json
index 5903d7850e..371ab67ef7 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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}",
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte
new file mode 100644
index 0000000000..b982e463ba
--- /dev/null
+++ b/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts
index e6d5941a18..0669e4f9d9 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -151,3 +151,21 @@ export const autoPlayVideo = persisted('auto-play-video', true, {});
export const alwaysLoadOriginalVideo = persisted('always-load-original-video', false, {});
export const recentAlbumsDropdown = persisted('recent-albums-open', true, {});
+
+export interface DuplicateSettings {
+ synchronizeAlbums: boolean;
+ synchronizeVisibility: boolean;
+ synchronizeFavorites: boolean;
+ synchronizeRating: boolean;
+ synchronizeDescpription: boolean;
+ synchronizeLocation: boolean;
+}
+
+export const duplicateSettings = persistedObject('duplicate-settings', {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescpription: false,
+ synchronizeLocation: false,
+});
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
index c6943c6491..3bc1853056 100644
--- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -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')}
/>
+
{/snippet}