From 19da67f064f925baea2d25c8197016d20b7c5476 Mon Sep 17 00:00:00 2001
From: Toni <51962051+EinToni@users.noreply.github.com>
Date: Wed, 30 Oct 2024 16:57:48 +0100
Subject: [PATCH 01/25] feat(web): Synchronize information from deduplicated
images
* Added new settings menu to the the deduplication tab.
* The toggable options in the settings are synchronization of: albums, favorites, ratings, description, visibility and location.
* When synchronizing the albums, the resolved images will be added to all albums of the duplicates.
* When synchronizing the favorite status, the resolved images will be marked as favorite, if at least one selectable image is marked as favorite.
* When synchronizing the ratings, the highest rating from the selectable images will be applied to the resolved image.
* When synchronizing the description, all descriptions from the selectable images will be merged into one description for the resolved image.
* When synchronizing the visibility, the most restrictive visibility setting from the selectable images will be applied to the resolved image.
* When synchronizing the location, if exactly one unique location exists among the selectable images, this location will be applied to the resolved image.
* There is no additional UI for these settings to keep the visual clutter minimal. The settings are applied automatically based on the user's preferences.
---
i18n/en.json | 12 +++
.../duplicate-settings-modal.svelte | 51 +++++++++++
web/src/lib/stores/preferences.store.ts | 18 ++++
.../[[assetId=id]]/+page.svelte | 91 ++++++++++++++++++-
4 files changed, 167 insertions(+), 5 deletions(-)
create mode 100644 web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte
diff --git a/i18n/en.json b/i18n/en.json
index a435c5986e788..77da9c8762f95 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -2185,6 +2185,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 0000000000000..b982e463bac0f
--- /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 9404d2bbdf837..1d9590c2ae109 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -149,3 +149,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 a3212fe009f50..668397a53fd3d 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,33 @@
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 { 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 { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
- import { locale } from '$lib/stores/preferences.store';
- import { stackAssets } from '$lib/utils/asset-utils';
+ import { duplicateSettings, locale } from '$lib/stores/preferences.store';
+ import { addAssetsToAlbums, 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,
+ deleteAssets,
+ deleteDuplicates,
+ getAllAlbums,
+ 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 +66,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 +115,66 @@
toastManager.success(message);
};
+ const getSyncedInfo = async (assetIds: string[]) => {
+ const allAssetsInfo = await Promise.all(
+ assetIds.map((assetId) => getAssetInfo({ ...authManager.params, id: assetId })),
+ );
+ const allAssetsAlbums = await Promise.all(assetIds.map((assetId) => getAllAlbums({ assetId })));
+ // If any of the assets is favorite, we consider the synced info as favorite
+ const isFavorite = allAssetsInfo.some((asset) => asset.isFavorite);
+ // Create a list of all album ids the assets are in
+ const albumIds = allAssetsAlbums.flat().map((album) => album.id);
+ // 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, albumIds, visibility, rating, description, latitude, longitude };
+ };
+
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
return withConfirmation(
async () => {
+ const { isFavorite, albumIds, 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) {
+ await addAssetsToAlbums(albumIds, [duplicateId], 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);
@@ -236,6 +308,15 @@
onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
aria-label={$t('show_keyboard_shortcuts')}
/>
+
{/snippet}
From 621af87888cea0bd0af7f5de806a2ad6061676c3 Mon Sep 17 00:00:00 2001
From: Toni <51962051+EinToni@users.noreply.github.com>
Date: Wed, 19 Nov 2025 18:02:01 +0100
Subject: [PATCH 02/25] Replace addAssetToAlbums with copyAsset
---
.../[[photos=photos]]/[[assetId=id]]/+page.svelte | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
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 668397a53fd3d..80134788ea0ae 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
@@ -12,12 +12,13 @@
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { duplicateSettings, locale } from '$lib/stores/preferences.store';
- import { addAssetsToAlbums, 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 type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk';
import {
AssetVisibility,
+ copyAsset,
deleteAssets,
deleteDuplicates,
getAllAlbums,
@@ -170,7 +171,12 @@
assetBulkUpdate.longitude = longitude;
}
if ($duplicateSettings.synchronizeAlbums) {
- await addAssetsToAlbums(albumIds, [duplicateId], true);
+ 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 } });
From 8bea89967c84c968d3af1150c1eb1fc18678f1c4 Mon Sep 17 00:00:00 2001
From: Toni <51962051+EinToni@users.noreply.github.com>
Date: Wed, 19 Nov 2025 18:52:36 +0100
Subject: [PATCH 03/25] fix linter
---
.../[[assetId=id]]/+page.svelte | 17 +++--------------
1 file changed, 3 insertions(+), 14 deletions(-)
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 80134788ea0ae..eb4ff6dab067d 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
@@ -16,15 +16,7 @@
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { handleError } from '$lib/utils/handle-error';
import type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk';
- import {
- AssetVisibility,
- copyAsset,
- deleteAssets,
- deleteDuplicates,
- getAllAlbums,
- getAssetInfo,
- updateAssets,
- } 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,
@@ -120,11 +112,8 @@
const allAssetsInfo = await Promise.all(
assetIds.map((assetId) => getAssetInfo({ ...authManager.params, id: assetId })),
);
- const allAssetsAlbums = await Promise.all(assetIds.map((assetId) => getAllAlbums({ assetId })));
// If any of the assets is favorite, we consider the synced info as favorite
const isFavorite = allAssetsInfo.some((asset) => asset.isFavorite);
- // Create a list of all album ids the assets are in
- const albumIds = allAssetsAlbums.flat().map((album) => album.id);
// Choose the most restrictive visibility level among the assets
const visibility = [
AssetVisibility.Locked,
@@ -142,13 +131,13 @@
const latitude = latitudes.size === 1 ? Array.from(latitudes)[0] : null;
const longitude = longitudes.size === 1 ? Array.from(longitudes)[0] : null;
- return { isFavorite, albumIds, visibility, rating, description, latitude, longitude };
+ return { isFavorite, visibility, rating, description, latitude, longitude };
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
return withConfirmation(
async () => {
- const { isFavorite, albumIds, visibility, rating, description, latitude, longitude } =
+ const { isFavorite, visibility, rating, description, latitude, longitude } =
await getSyncedInfo(duplicateAssetIds);
let assetBulkUpdate: AssetBulkUpdateDto = {
ids: duplicateAssetIds,
From 0408ba76a82cdee9534cb4284a31a27a19888754 Mon Sep 17 00:00:00 2001
From: Phlogi
Date: Tue, 6 Jan 2026 17:28:58 +0100
Subject: [PATCH 04/25] feat(web): add duplicate sync fields and fix typo
---
.../duplicate-settings-modal.svelte | 15 ++++++--
web/src/lib/stores/preferences.store.ts | 36 ++++++++++++++++---
2 files changed, 45 insertions(+), 6 deletions(-)
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
index b982e463bac0f..6335eee5a2ecf 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte
@@ -8,7 +8,15 @@
onClose: (settings?: DuplicateSettings) => void;
}
let { settings: initialValues, onClose }: Props = $props();
- let settings = $state(initialValues);
+ let settings = $state({
+ synchronizeAlbums: initialValues.synchronizeAlbums ?? false,
+ synchronizeVisibility: initialValues.synchronizeVisibility ?? false,
+ synchronizeFavorites: initialValues.synchronizeFavorites ?? false,
+ synchronizeRating: initialValues.synchronizeRating ?? false,
+ synchronizeDescription: initialValues.synchronizeDescription ?? initialValues.synchronizeDescpription ?? false,
+ synchronizeLocation: initialValues.synchronizeLocation ?? false,
+ synchronizeTags: initialValues.synchronizeTags ?? false,
+ });
const onsubmit = (event: Event) => {
event.preventDefault();
@@ -30,7 +38,7 @@
-
+
@@ -38,6 +46,9 @@
+
+
+
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts
index 1d9590c2ae109..f10106034b59f 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -49,7 +49,7 @@ const defaultMapSettings = {
const persistedObject = (key: string, defaults: T) =>
persisted(key, defaults, {
serializer: {
- parse: (text) => ({ ...defaultMapSettings, ...JSON.parse(text ?? null) }),
+ parse: (text) => ({ ...defaults, ...JSON.parse(text ?? null) }),
stringify: JSON.stringify,
},
});
@@ -155,15 +155,43 @@ export interface DuplicateSettings {
synchronizeVisibility: boolean;
synchronizeFavorites: boolean;
synchronizeRating: boolean;
- synchronizeDescpription: boolean;
+ synchronizeDescription: boolean;
synchronizeLocation: boolean;
+ synchronizeTags: boolean;
+ /** @deprecated typo retained for migration */
+ synchronizeDescpription?: boolean;
}
-export const duplicateSettings = persistedObject('duplicate-settings', {
+const defaultDuplicateSettings: DuplicateSettings = {
synchronizeAlbums: false,
synchronizeVisibility: false,
synchronizeFavorites: false,
synchronizeRating: false,
- synchronizeDescpription: false,
+ synchronizeDescription: false,
synchronizeLocation: false,
+ synchronizeTags: false,
+};
+
+const normalizeDuplicateSettings = (
+ settings: Partial & { synchronizeDescpription?: boolean },
+): DuplicateSettings => {
+ const { synchronizeDescpription: _deprecated, ...rest } = settings;
+ return {
+ ...defaultDuplicateSettings,
+ ...rest,
+ synchronizeDescription:
+ settings.synchronizeDescription ??
+ settings.synchronizeDescpription ??
+ defaultDuplicateSettings.synchronizeDescription,
+ };
+};
+
+export const duplicateSettings = persisted('duplicate-settings', defaultDuplicateSettings, {
+ serializer: {
+ parse: (text) => {
+ const parsed = text ? JSON.parse(text) : null;
+ return normalizeDuplicateSettings(parsed ?? {});
+ },
+ stringify: JSON.stringify,
+ },
});
From 7425c377137820d1f6c07604a460c2e44b319e3c Mon Sep 17 00:00:00 2001
From: Phlogi
Date: Tue, 6 Jan 2026 22:05:39 +0100
Subject: [PATCH 05/25] feat(web): add tag sync and enhance duplicate
resolution
This update introduces tag synchronization for duplicate resolution,
ensuring all unique tag IDs from duplicates are applied to kept assets.
The visibility sync logic is updated to use a simplified ordering, as the hidden status items will never show up in a duplicate set.
Album synchronization now merges albums directly via addAssetsToAlbums; as the approach with copyAsset API endpoint was ineffiecient.
Description, rating, and location sync logic is improved for correctness.
and deduplication. i18n strings were added / updated.
---
i18n/en.json | 4 +-
.../[[assetId=id]]/+page.svelte | 179 ++++++++++++++----
2 files changed, 143 insertions(+), 40 deletions(-)
diff --git a/i18n/en.json b/i18n/en.json
index 77da9c8762f95..358151a3fd055 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -2195,8 +2195,10 @@
"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_tags": "Synchronize Tags",
+ "synchronize_tags_description": "Apply all unique tags from the duplicates to the resolved asset.",
"synchronize_visibility": "Synchronize Visibility setting",
- "synchronize_visibility_description": "Apply the most restrictive visibility present among the duplicates (Locked → Hidden → Archive → Timeline).",
+ "synchronize_visibility_description": "Apply the most restrictive visibility present among the duplicates (Locked → Archive → Timeline).",
"tag": "Tag",
"tag_assets": "Tag assets",
"tag_created": "Created tag: {tag}",
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 eb4ff6dab067d..1ee7e51149740 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
@@ -16,7 +16,16 @@
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
import { handleError } from '$lib/utils/handle-error';
import type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk';
- import { AssetVisibility, copyAsset, deleteAssets, deleteDuplicates, getAssetInfo, updateAssets } from '@immich/sdk';
+ import {
+ addAssetsToAlbums,
+ AssetVisibility,
+ bulkTagAssets,
+ deleteAssets,
+ deleteDuplicates,
+ getAllAlbums,
+ getAssetInfo,
+ updateAssets,
+ } from '@immich/sdk';
import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui';
import {
mdiCheckOutline,
@@ -30,6 +39,7 @@
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
+ import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
interface Props {
@@ -109,75 +119,166 @@
};
const getSyncedInfo = async (assetIds: string[]) => {
+ if (assetIds.length === 0) {
+ return {
+ isFavorite: false,
+ visibility: undefined,
+ rating: 0,
+ description: null,
+ latitude: null,
+ longitude: null,
+ tagIds: [],
+ };
+ }
+
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 most restrictive user-visible level (Hidden is internal-only)
+ const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline];
+ let visibility = visibilityOrder.find((level) => allAssetsInfo.some((asset) => asset.visibility === level));
+ if (!visibility && allAssetsInfo.some((asset) => asset.visibility === AssetVisibility.Hidden)) {
+ visibility = AssetVisibility.Hidden;
+ }
// 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 };
+ let rating = 0;
+ for (const asset of allAssetsInfo) {
+ const assetRating = asset.exifInfo?.rating ?? 0;
+ if (assetRating > rating) {
+ rating = assetRating;
+ }
+ }
+ // Concatenate unique non-empty description lines to avoid duplicates across multi-line values
+ const uniqueNonEmptyLines = (values: Array) => {
+ const unique = new SvelteSet();
+ const lines: string[] = [];
+ for (const value of values) {
+ if (!value) {
+ continue;
+ }
+ for (const line of value.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || unique.has(trimmed)) {
+ continue;
+ }
+ unique.add(trimmed);
+ lines.push(trimmed);
+ }
+ }
+ return lines;
+ };
+ const description =
+ uniqueNonEmptyLines(allAssetsInfo.map((asset) => asset.exifInfo?.description)).join('\n') || null;
+
+ // Helper: return unique numeric coordinate pair or null
+ const getUniqueCoordinate = (assets: AssetResponseDto[], key: 'latitude' | 'longitude'): number | null => {
+ const values = assets
+ .map((asset) => asset.exifInfo?.[key])
+ .filter((value): value is number => Number.isFinite(value));
+
+ if (values.length === 0) {
+ return null;
+ }
+
+ const unique = new SvelteSet(values);
+ return unique.size === 1 ? Array.from(unique)[0] : null;
+ };
+
+ const latitude: number | null = getUniqueCoordinate(allAssetsInfo, 'latitude');
+ const longitude: number | null = getUniqueCoordinate(allAssetsInfo, 'longitude');
+
+ // Collect all unique tag IDs from all assets: flatten tags, extract IDs, deduplicate
+ const tagIds = [
+ ...new SvelteSet(
+ allAssetsInfo
+ .flatMap((asset) => asset.tags ?? [])
+ .map((tag) => tag.id)
+ .filter((id): id is string => !!id),
+ ),
+ ];
+
+ return { isFavorite, visibility, rating, description, latitude, longitude, tagIds };
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
+ const forceDelete = !featureFlagsManager.value.trash;
+ const shouldConfirmDelete = trashIds.length > 0 && forceDelete;
+
return withConfirmation(
async () => {
- const { isFavorite, visibility, rating, description, latitude, longitude } =
- await getSyncedInfo(duplicateAssetIds);
+ const idsToKeep = duplicateAssetIds.filter((id) => !trashIds.includes(id));
+
+ const needsSyncedInfo =
+ $duplicateSettings.synchronizeFavorites ||
+ $duplicateSettings.synchronizeVisibility ||
+ $duplicateSettings.synchronizeRating ||
+ $duplicateSettings.synchronizeDescription ||
+ $duplicateSettings.synchronizeLocation ||
+ $duplicateSettings.synchronizeTags;
+ const syncedInfo = needsSyncedInfo ? await getSyncedInfo(duplicateAssetIds) : null;
+
let assetBulkUpdate: AssetBulkUpdateDto = {
- ids: duplicateAssetIds,
+ ids: idsToKeep,
duplicateId: null,
};
- if ($duplicateSettings.synchronizeFavorites) {
- assetBulkUpdate.isFavorite = isFavorite;
+ if ($duplicateSettings.synchronizeFavorites && syncedInfo) {
+ assetBulkUpdate.isFavorite = syncedInfo.isFavorite;
}
- if ($duplicateSettings.synchronizeVisibility) {
- assetBulkUpdate.visibility = visibility;
+ if ($duplicateSettings.synchronizeVisibility && syncedInfo) {
+ assetBulkUpdate.visibility = syncedInfo.visibility;
}
- if ($duplicateSettings.synchronizeRating) {
- assetBulkUpdate.rating = rating;
+ if ($duplicateSettings.synchronizeRating && syncedInfo) {
+ assetBulkUpdate.rating = syncedInfo.rating;
}
- if ($duplicateSettings.synchronizeDescpription) {
- assetBulkUpdate.description = description;
+ if ($duplicateSettings.synchronizeDescription && syncedInfo && syncedInfo.description !== null) {
+ assetBulkUpdate.description = syncedInfo.description;
}
- if ($duplicateSettings.synchronizeLocation && latitude !== null && longitude !== null) {
- assetBulkUpdate.latitude = latitude;
- assetBulkUpdate.longitude = longitude;
+ // If all assets have the same location, use it; otherwise don't set it (leave as-is)
+ // Note: We cannot explicitly clear location via updateAssets API - it doesn't accept null
+ // The keeper asset will retain its original location if coordinates differ
+ if (
+ $duplicateSettings.synchronizeLocation &&
+ syncedInfo &&
+ syncedInfo.latitude !== null &&
+ syncedInfo.longitude !== null
+ ) {
+ assetBulkUpdate.latitude = syncedInfo.latitude;
+ assetBulkUpdate.longitude = syncedInfo.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 } });
+ const mergedAlbumIds = new SvelteSet();
+ for (const sourceId of duplicateAssetIds) {
+ const albums = await getAllAlbums({ assetId: sourceId });
+ for (const album of albums) {
+ mergedAlbumIds.add(album.id);
}
}
+
+ const albumIds = [...mergedAlbumIds];
+ if (albumIds.length > 0 && idsToKeep.length > 0) {
+ await addAssetsToAlbums({ albumsAddAssetsDto: { albumIds, assetIds: idsToKeep } });
+ }
+ }
+
+ if ($duplicateSettings.synchronizeTags && idsToKeep.length > 0 && syncedInfo && syncedInfo.tagIds.length > 0) {
+ await bulkTagAssets({ tagBulkAssetsDto: { tagIds: syncedInfo.tagIds, assetIds: idsToKeep } });
}
- await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !featureFlagsManager.value.trash } });
await updateAssets({ assetBulkUpdateDto: assetBulkUpdate });
+ await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: forceDelete } });
+ // This line ensures that once a duplicate group is resolved, it disappears from the
+ // duplicates list shown to the user, maintaining consistent UI state
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
deletedNotification(trashIds.length);
await navigateToIndex(duplicatesIndex);
},
- trashIds.length > 0 && !featureFlagsManager.value.trash ? $t('delete_duplicates_confirmation') : undefined,
- trashIds.length > 0 && !featureFlagsManager.value.trash ? $t('permanently_delete') : undefined,
+ shouldConfirmDelete ? $t('delete_duplicates_confirmation') : undefined,
+ shouldConfirmDelete ? $t('permanently_delete') : undefined,
);
};
From 29bfb355fef03632b7baff91a860aff868bb1727 Mon Sep 17 00:00:00 2001
From: Phlogi
Date: Fri, 16 Jan 2026 20:47:28 +0100
Subject: [PATCH 06/25] feat(server): move duplicate resolution to backend with
sync and stacking
Moves duplicate metadata synchronization from frontend to backend, enabling robust
batch operations and proper validation. This is an improved refactor of PR #13851.
New endpoints:
- POST /duplicates/resolve - batch resolve with configurable metadata sync
- POST /duplicates/stack - create stacks from duplicate groups
- GET /duplicates - now includes suggestedKeepAssetIds based on file size and EXIF
Key changes:
- Move sync logic (albums, tags, favorites, ratings, descriptions, location, visibility) to server
- Add server-side metadata merge policies with proper conflict resolution
- Replace client-side resolution logic with new backend endpoints
- Add comprehensive E2E tests (70+ test cases) and unit tests
- Update OpenAPI specs and TypeScript SDK
No breaking changes - only additions to existing API.
---
e2e/src/api/specs/duplicate.e2e-spec.ts | 1020 +++++++++++++++++
e2e/src/fixtures.ts | 2 +
e2e/src/utils.ts | 9 +
mobile/openapi/README.md | 8 +
mobile/openapi/lib/api.dart | 6 +
mobile/openapi/lib/api/duplicates_api.dart | 112 ++
mobile/openapi/lib/api_client.dart | 12 +
.../lib/model/duplicate_resolve_dto.dart | 109 ++
.../model/duplicate_resolve_group_dto.dart | 121 ++
.../model/duplicate_resolve_response_dto.dart | 180 +++
.../model/duplicate_resolve_result_dto.dart | 201 ++++
.../model/duplicate_resolve_settings_dto.dart | 154 +++
.../lib/model/duplicate_response_dto.dart | 17 +-
.../lib/model/duplicate_stack_dto.dart | 128 +++
open-api/immich-openapi-specs.json | 292 +++++
open-api/typescript-sdk/src/fetch-client.ts | 105 +-
.../src/controllers/duplicate.controller.ts | 38 +-
server/src/dtos/duplicate.dto.ts | 119 ++
server/src/queries/album.repository.sql | 22 +
server/src/queries/duplicate.repository.sql | 110 +-
server/src/repositories/album.repository.ts | 45 +-
.../src/repositories/duplicate.repository.ts | 113 +-
server/src/services/duplicate.service.spec.ts | 249 ++++
server/src/services/duplicate.service.ts | 297 ++++-
server/src/utils/duplicate-resolve.spec.ts | 324 ++++++
server/src/utils/duplicate-resolve.ts | 197 ++++
server/src/utils/duplicate.spec.ts | 176 +++
server/src/utils/duplicate.ts | 60 +
.../duplicates-compare-control.svelte | 18 +-
web/src/lib/utils/duplicate-utils.spec.ts | 37 -
web/src/lib/utils/duplicate-utils.ts | 30 -
.../[[assetId=id]]/+page.svelte | 232 +---
32 files changed, 4236 insertions(+), 307 deletions(-)
create mode 100644 e2e/src/api/specs/duplicate.e2e-spec.ts
create mode 100644 mobile/openapi/lib/model/duplicate_resolve_dto.dart
create mode 100644 mobile/openapi/lib/model/duplicate_resolve_group_dto.dart
create mode 100644 mobile/openapi/lib/model/duplicate_resolve_response_dto.dart
create mode 100644 mobile/openapi/lib/model/duplicate_resolve_result_dto.dart
create mode 100644 mobile/openapi/lib/model/duplicate_resolve_settings_dto.dart
create mode 100644 mobile/openapi/lib/model/duplicate_stack_dto.dart
create mode 100644 server/src/utils/duplicate-resolve.spec.ts
create mode 100644 server/src/utils/duplicate-resolve.ts
create mode 100644 server/src/utils/duplicate.spec.ts
create mode 100644 server/src/utils/duplicate.ts
delete mode 100644 web/src/lib/utils/duplicate-utils.spec.ts
delete mode 100644 web/src/lib/utils/duplicate-utils.ts
diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts
new file mode 100644
index 0000000000000..8d2627ecb525a
--- /dev/null
+++ b/e2e/src/api/specs/duplicate.e2e-spec.ts
@@ -0,0 +1,1020 @@
+import { LoginResponseDto } from '@immich/sdk';
+import { createUserDto, uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { app, utils } from 'src/utils';
+import request from 'supertest';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+describe('/duplicates', () => {
+ let admin: LoginResponseDto;
+ let user1: LoginResponseDto;
+ let user2: LoginResponseDto;
+
+ beforeAll(async () => {
+ await utils.resetDatabase();
+
+ admin = await utils.adminSetup();
+
+ [user1, user2] = await Promise.all([
+ utils.userSetup(admin.accessToken, createUserDto.user1),
+ utils.userSetup(admin.accessToken, createUserDto.user2),
+ ]);
+ });
+
+ beforeEach(async () => {
+ // Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
+ // Note: We don't reset users since they're set up once in beforeAll
+ // Stack must be reset before asset due to foreign key constraint
+ await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
+ });
+
+ describe('GET /duplicates', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app).get('/duplicates');
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should return empty array when no duplicates', async () => {
+ const { status, body } = await request(app)
+ .get('/duplicates')
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual([]);
+ });
+
+ it('should return duplicate groups with suggestedKeepAssetIds', async () => {
+ // Create assets with different file sizes for duplicate detection
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Manually set duplicateId on both assets to create a duplicate group
+ const duplicateId = '00000000-0000-4000-8000-000000000001';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .get('/duplicates')
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(status).toBe(200);
+ expect(body).toEqual([
+ {
+ duplicateId,
+ assets: expect.arrayContaining([
+ expect.objectContaining({ id: asset1.id }),
+ expect.objectContaining({ id: asset2.id }),
+ ]),
+ suggestedKeepAssetIds: expect.any(Array),
+ },
+ ]);
+ expect(body[0].suggestedKeepAssetIds.length).toBe(1);
+ });
+ });
+
+ describe('DELETE /duplicates', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app)
+ .delete('/duplicates')
+ .send({ ids: [uuidDto.dummy] });
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+ });
+
+ describe('POST /duplicates/resolve', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .send({
+ groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should return failure for non-existent duplicate group', async () => {
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ status: 'COMPLETED',
+ results: [
+ {
+ duplicateId: uuidDto.dummy,
+ status: 'FAILED',
+ reason: expect.stringContaining('not found or access denied'),
+ },
+ ],
+ });
+ });
+
+ it('should resolve duplicate group with keepers', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000002';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ status: 'COMPLETED',
+ results: [
+ {
+ duplicateId,
+ status: 'SUCCESS',
+ },
+ ],
+ });
+
+ // Verify side effects: duplicateId cleared on kept asset
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.duplicateId).toBeNull();
+
+ // Verify side effects: trashed asset is trashed and duplicateId cleared
+ const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
+ expect(trashedAsset.isTrashed).toBe(true);
+ expect(trashedAsset.duplicateId).toBeNull();
+ });
+
+ it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000003';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('disjoint');
+ });
+
+ it('should require keepAssetIds when partially trashing', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000004';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('must cover all assets');
+ });
+
+ it('should reject partial resolution (not all assets covered)', async () => {
+ const [asset1, asset2, asset3] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000010';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('must cover all assets');
+ });
+
+ it('should reject asset not in duplicate group', async () => {
+ const [asset1, asset2, outsideAsset] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000011';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('not a member of duplicate group');
+ });
+
+ it('should allow trash-all without keepers', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000012';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body).toEqual({
+ status: 'COMPLETED',
+ results: [
+ {
+ duplicateId,
+ status: 'SUCCESS',
+ },
+ ],
+ });
+
+ // Verify both assets are trashed
+ const [asset1Info, asset2Info] = await Promise.all([
+ utils.getAssetInfo(user1.accessToken, asset1.id),
+ utils.getAssetInfo(user1.accessToken, asset2.id),
+ ]);
+
+ expect(asset1Info.isTrashed).toBe(true);
+ expect(asset1Info.duplicateId).toBeNull();
+ expect(asset2Info.isTrashed).toBe(true);
+ expect(asset2Info.duplicateId).toBeNull();
+ });
+
+ it('should reject cross-user duplicate group access', async () => {
+ const asset1 = await utils.createAsset(user1.accessToken);
+ const asset2 = await utils.createAsset(user2.accessToken);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000013';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
+
+ // User1 tries to resolve a group containing user2's asset
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('FAILED');
+ expect(body.results[0].reason).toContain('not a member of duplicate group');
+ });
+
+ it('should synchronize favorites when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Mark one asset as favorite
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], isFavorite: true });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000020';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: true,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify favorite was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.isFavorite).toBe(true);
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize visibility when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Archive one asset
+ await utils.archiveAssets(user1.accessToken, [asset2.id]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000021';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: true,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify visibility was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.visibility).toBe('archive');
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize rating when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Set rating on one asset
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], rating: 5 });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000022';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: true,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify rating was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.exifInfo?.rating).toBe(5);
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize description when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Set description on one asset
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], description: 'Test description for duplicate' });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000023';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: true,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify description was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize location when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Set location on one asset
+ await request(app)
+ .put('/assets')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000024';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: true,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify location was synchronized to keeper
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
+ expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
+ expect(keptAsset.duplicateId).toBeNull();
+ });
+
+ it('should synchronize albums when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Create albums and add assets to different albums
+ const album1 = await utils.createAlbum(user1.accessToken, {
+ albumName: 'Album 1',
+ assetIds: [asset1.id],
+ });
+ const album2 = await utils.createAlbum(user1.accessToken, {
+ albumName: 'Album 2',
+ assetIds: [asset2.id],
+ });
+
+ const duplicateId = '00000000-0000-4000-8000-000000000025';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: true,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify keeper is now in both albums
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.duplicateId).toBeNull();
+
+ // Check albums directly
+ const { status: album1Status, body: album1Body } = await request(app)
+ .get(`/albums/${album1.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+ const { status: album2Status, body: album2Body } = await request(app)
+ .get(`/albums/${album2.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ expect(album1Status).toBe(200);
+ expect(album2Status).toBe(200);
+ expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
+ expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
+ });
+
+ it('should synchronize tags when enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ // Wait for metadata extraction to complete before adding tags
+ // Otherwise, metadata jobs will race and overwrite our tags
+ await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+ // Create tags and tag assets differently
+ const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
+ await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
+ await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000026';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: true,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify keeper has both tags
+ const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(keptAsset.duplicateId).toBeNull();
+ expect(keptAsset.tags).toBeDefined();
+ const tagIds = keptAsset.tags?.map((t) => t.id) || [];
+ expect(tagIds).toContain(tags[0].id);
+ expect(tagIds).toContain(tags[1].id);
+ });
+
+ it('should handle batch resolve with mixed success and failure', async () => {
+ // Create first group that will succeed
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+ const duplicateId1 = '00000000-0000-4000-8000-000000000027';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
+
+ // Create second group with non-existent duplicate ID (will fail)
+ const fakeId = '00000000-0000-4000-8000-000000000099';
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [
+ { duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
+ { duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
+ ],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.status).toBe('COMPLETED');
+ expect(body.results).toHaveLength(2);
+
+ // First group should succeed
+ expect(body.results[0].duplicateId).toBe(duplicateId1);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Second group should fail
+ expect(body.results[1].duplicateId).toBe(fakeId);
+ expect(body.results[1].status).toBe('FAILED');
+ expect(body.results[1].reason).toContain('not found or access denied');
+
+ // Verify first group was actually resolved despite second failure
+ const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
+ expect(asset1Info.duplicateId).toBeNull();
+ const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
+ expect(asset2Info.isTrashed).toBe(true);
+ });
+
+ it('should trash assets when trash is enabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000028';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ // Ensure trash is enabled (default)
+ const config = await utils.getSystemConfig(admin.accessToken);
+ expect(config.trash.enabled).toBe(true);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Verify asset is trashed (not deleted)
+ const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
+ expect(trashedAsset.isTrashed).toBe(true);
+ });
+
+ it('should delete assets when trash is disabled', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000029';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ // Disable trash
+ await request(app)
+ .put('/system-config')
+ .set('Authorization', `Bearer ${admin.accessToken}`)
+ .send({
+ trash: { enabled: false, days: 30 },
+ });
+
+ const { status, body } = await request(app)
+ .post('/duplicates/resolve')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
+ settings: {
+ synchronizeAlbums: false,
+ synchronizeVisibility: false,
+ synchronizeFavorites: false,
+ synchronizeRating: false,
+ synchronizeDescription: false,
+ synchronizeLocation: false,
+ synchronizeTags: false,
+ },
+ });
+
+ expect(status).toBe(200);
+ expect(body.results[0].status).toBe('SUCCESS');
+
+ // Asset should be marked as deleted (force delete)
+ const { status: getStatus } = await request(app)
+ .get(`/assets/${asset2.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+
+ // Asset should still be accessible (soft deleted) but marked as deleted
+ expect(getStatus).toBe(200);
+
+ // Re-enable trash for other tests
+ await utils.resetAdminConfig(admin.accessToken);
+ });
+ });
+
+ describe('POST /duplicates/stack', () => {
+ it('should require authentication', async () => {
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .send({
+ duplicateId: uuidDto.dummy,
+ assetIds: [uuidDto.dummy, uuidDto.dummy2],
+ });
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorDto.unauthorized);
+ });
+
+ it('should require valid duplicate group', async () => {
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId: uuidDto.dummy,
+ assetIds: [uuidDto.dummy, uuidDto.dummy2],
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest(expect.stringContaining('not found or access denied')));
+ });
+
+ it('should require at least two assets', async () => {
+ const asset = await utils.createAsset(user1.accessToken);
+ const duplicateId = '00000000-0000-4000-8000-000000000005';
+ await utils.setAssetDuplicateId(user1.accessToken, asset.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId,
+ assetIds: [asset.id],
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest());
+ });
+
+ it('should create stack from duplicate group', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000006';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId,
+ assetIds: [asset1.id, asset2.id],
+ });
+
+ expect(status).toBe(201);
+ expect(body).toEqual({
+ id: expect.any(String),
+ primaryAssetId: asset1.id,
+ assets: expect.arrayContaining([
+ expect.objectContaining({ id: asset1.id }),
+ expect.objectContaining({ id: asset2.id }),
+ ]),
+ });
+
+ // Verify side effects: stack created and duplicateIds cleared
+ const stackId = body.id;
+ const [asset1Info, asset2Info] = await Promise.all([
+ utils.getAssetInfo(user1.accessToken, asset1.id),
+ utils.getAssetInfo(user1.accessToken, asset2.id),
+ ]);
+
+ expect(asset1Info.stack?.id).toBe(stackId);
+ expect(asset1Info.duplicateId).toBeNull();
+ expect(asset2Info.stack?.id).toBe(stackId);
+ expect(asset2Info.duplicateId).toBeNull();
+ });
+
+ it('should respect primaryAssetId when creating stack', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000007';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId,
+ assetIds: [asset1.id, asset2.id],
+ primaryAssetId: asset2.id,
+ });
+
+ expect(status).toBe(201);
+ expect(body.primaryAssetId).toBe(asset2.id);
+ });
+
+ it('should reject assets not in duplicate group', async () => {
+ const [asset1, asset2, asset3] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000008';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+ // asset3 is NOT in the duplicate group
+
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId,
+ assetIds: [asset1.id, asset3.id],
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest(expect.stringContaining('not a member of duplicate group')));
+ });
+
+ it('should reject primaryAssetId not in assetIds', async () => {
+ const [asset1, asset2, asset3] = await Promise.all([
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ utils.createAsset(user1.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000014';
+ await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
+ await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
+
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId,
+ assetIds: [asset1.id, asset2.id],
+ primaryAssetId: asset3.id, // asset3 is in group but not in assetIds
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest(expect.stringContaining('primaryAssetId must be in assetIds')));
+ });
+
+ it('should prevent access to other user duplicate groups', async () => {
+ const [asset1, asset2] = await Promise.all([
+ utils.createAsset(user2.accessToken),
+ utils.createAsset(user2.accessToken),
+ ]);
+
+ const duplicateId = '00000000-0000-4000-8000-000000000009';
+ await utils.setAssetDuplicateId(user2.accessToken, asset1.id, duplicateId);
+ await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
+
+ // user1 tries to stack user2's duplicates
+ const { status, body } = await request(app)
+ .post('/duplicates/stack')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({
+ duplicateId,
+ assetIds: [asset1.id, asset2.id],
+ });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorDto.badRequest(expect.stringContaining('not found or access denied')));
+ });
+ });
+});
diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts
index 9e311c896df11..1e03ad6d24f15 100644
--- a/e2e/src/fixtures.ts
+++ b/e2e/src/fixtures.ts
@@ -2,6 +2,8 @@ export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
+ dummy: '00000000-0000-4000-a000-000000000001',
+ dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 7307f8785456b..20bf753c82f96 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -499,6 +499,15 @@ export const utils = {
createStack: (accessToken: string, assetIds: string[]) =>
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
+ setAssetDuplicateId: async (accessToken: string, assetId: string, duplicateId: string | null) => {
+ // For testing duplicates, directly set the duplicateId via SQL
+ // This is needed because duplicate detection normally happens via ML pipeline
+ if (!client) {
+ throw new Error('Database client not initialized');
+ }
+ await client.query(`UPDATE "asset" SET "duplicateId" = $1 WHERE "id" = $2`, [duplicateId, assetId]);
+ },
+
upsertTags: (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 5ca810fe4830d..a8871046075cb 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -156,6 +156,8 @@ Class | Method | HTTP request | Description
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
+*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
+*DuplicatesApi* | [**stackDuplicates**](doc//DuplicatesApi.md#stackduplicates) | **POST** /duplicates/stack | Stack duplicates
*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | Create a face
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | Delete a face
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset
@@ -421,7 +423,13 @@ Class | Method | HTTP request | Description
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [DownloadUpdate](doc//DownloadUpdate.md)
- [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md)
+ - [DuplicateResolveDto](doc//DuplicateResolveDto.md)
+ - [DuplicateResolveGroupDto](doc//DuplicateResolveGroupDto.md)
+ - [DuplicateResolveResponseDto](doc//DuplicateResolveResponseDto.md)
+ - [DuplicateResolveResultDto](doc//DuplicateResolveResultDto.md)
+ - [DuplicateResolveSettingsDto](doc//DuplicateResolveSettingsDto.md)
- [DuplicateResponseDto](doc//DuplicateResponseDto.md)
+ - [DuplicateStackDto](doc//DuplicateStackDto.md)
- [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
- [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 90e426b5472de..4ce7668205b19 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -161,7 +161,13 @@ part 'model/download_response.dart';
part 'model/download_response_dto.dart';
part 'model/download_update.dart';
part 'model/duplicate_detection_config.dart';
+part 'model/duplicate_resolve_dto.dart';
+part 'model/duplicate_resolve_group_dto.dart';
+part 'model/duplicate_resolve_response_dto.dart';
+part 'model/duplicate_resolve_result_dto.dart';
+part 'model/duplicate_resolve_settings_dto.dart';
part 'model/duplicate_response_dto.dart';
+part 'model/duplicate_stack_dto.dart';
part 'model/email_notifications_response.dart';
part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart';
diff --git a/mobile/openapi/lib/api/duplicates_api.dart b/mobile/openapi/lib/api/duplicates_api.dart
index 7fa7b368b576e..34bff3d31b8d7 100644
--- a/mobile/openapi/lib/api/duplicates_api.dart
+++ b/mobile/openapi/lib/api/duplicates_api.dart
@@ -163,4 +163,116 @@ class DuplicatesApi {
}
return null;
}
+
+ /// Resolve duplicate groups
+ ///
+ /// Resolve duplicate groups by synchronizing metadata across assets and optionally deleting/trashing duplicates.
+ ///
+ /// Note: This method returns the HTTP [Response].
+ ///
+ /// Parameters:
+ ///
+ /// * [DuplicateResolveDto] duplicateResolveDto (required):
+ Future resolveDuplicatesWithHttpInfo(DuplicateResolveDto duplicateResolveDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/duplicates/resolve';
+
+ // ignore: prefer_final_locals
+ Object? postBody = duplicateResolveDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'POST',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Resolve duplicate groups
+ ///
+ /// Resolve duplicate groups by synchronizing metadata across assets and optionally deleting/trashing duplicates.
+ ///
+ /// Parameters:
+ ///
+ /// * [DuplicateResolveDto] duplicateResolveDto (required):
+ Future resolveDuplicates(DuplicateResolveDto duplicateResolveDto,) async {
+ final response = await resolveDuplicatesWithHttpInfo(duplicateResolveDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DuplicateResolveResponseDto',) as DuplicateResolveResponseDto;
+
+ }
+ return null;
+ }
+
+ /// Stack duplicates
+ ///
+ /// Create a stack from assets in a duplicate group and clear their duplicate membership.
+ ///
+ /// Note: This method returns the HTTP [Response].
+ ///
+ /// Parameters:
+ ///
+ /// * [DuplicateStackDto] duplicateStackDto (required):
+ Future stackDuplicatesWithHttpInfo(DuplicateStackDto duplicateStackDto,) async {
+ // ignore: prefer_const_declarations
+ final apiPath = r'/duplicates/stack';
+
+ // ignore: prefer_final_locals
+ Object? postBody = duplicateStackDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ apiPath,
+ 'POST',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Stack duplicates
+ ///
+ /// Create a stack from assets in a duplicate group and clear their duplicate membership.
+ ///
+ /// Parameters:
+ ///
+ /// * [DuplicateStackDto] duplicateStackDto (required):
+ Future stackDuplicates(DuplicateStackDto duplicateStackDto,) async {
+ final response = await stackDuplicatesWithHttpInfo(duplicateStackDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto;
+
+ }
+ return null;
+ }
}
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 7f5cd50ed4a0f..fd4e07d0e5b6c 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -368,8 +368,20 @@ class ApiClient {
return DownloadUpdate.fromJson(value);
case 'DuplicateDetectionConfig':
return DuplicateDetectionConfig.fromJson(value);
+ case 'DuplicateResolveDto':
+ return DuplicateResolveDto.fromJson(value);
+ case 'DuplicateResolveGroupDto':
+ return DuplicateResolveGroupDto.fromJson(value);
+ case 'DuplicateResolveResponseDto':
+ return DuplicateResolveResponseDto.fromJson(value);
+ case 'DuplicateResolveResultDto':
+ return DuplicateResolveResultDto.fromJson(value);
+ case 'DuplicateResolveSettingsDto':
+ return DuplicateResolveSettingsDto.fromJson(value);
case 'DuplicateResponseDto':
return DuplicateResponseDto.fromJson(value);
+ case 'DuplicateStackDto':
+ return DuplicateStackDto.fromJson(value);
case 'EmailNotificationsResponse':
return EmailNotificationsResponse.fromJson(value);
case 'EmailNotificationsUpdate':
diff --git a/mobile/openapi/lib/model/duplicate_resolve_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_dto.dart
new file mode 100644
index 0000000000000..a11e931d03bed
--- /dev/null
+++ b/mobile/openapi/lib/model/duplicate_resolve_dto.dart
@@ -0,0 +1,109 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DuplicateResolveDto {
+ /// Returns a new [DuplicateResolveDto] instance.
+ DuplicateResolveDto({
+ this.groups = const [],
+ required this.settings,
+ });
+
+ /// List of duplicate groups to resolve
+ List groups;
+
+ /// Settings for synchronization behavior
+ DuplicateResolveSettingsDto settings;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveDto &&
+ _deepEquality.equals(other.groups, groups) &&
+ other.settings == settings;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (groups.hashCode) +
+ (settings.hashCode);
+
+ @override
+ String toString() => 'DuplicateResolveDto[groups=$groups, settings=$settings]';
+
+ Map toJson() {
+ final json = {};
+ json[r'groups'] = this.groups;
+ json[r'settings'] = this.settings;
+ return json;
+ }
+
+ /// Returns a new [DuplicateResolveDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static DuplicateResolveDto? fromJson(dynamic value) {
+ upgradeDto(value, "DuplicateResolveDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return DuplicateResolveDto(
+ groups: DuplicateResolveGroupDto.listFromJson(json[r'groups']),
+ settings: DuplicateResolveSettingsDto.fromJson(json[r'settings'])!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = DuplicateResolveDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of DuplicateResolveDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = DuplicateResolveDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'groups',
+ 'settings',
+ };
+}
+
diff --git a/mobile/openapi/lib/model/duplicate_resolve_group_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_group_dto.dart
new file mode 100644
index 0000000000000..7a16d6303ddcd
--- /dev/null
+++ b/mobile/openapi/lib/model/duplicate_resolve_group_dto.dart
@@ -0,0 +1,121 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DuplicateResolveGroupDto {
+ /// Returns a new [DuplicateResolveGroupDto] instance.
+ DuplicateResolveGroupDto({
+ required this.duplicateId,
+ this.keepAssetIds = const [],
+ this.trashAssetIds = const [],
+ });
+
+ String duplicateId;
+
+ /// Asset IDs to keep (will have duplicateId cleared)
+ List keepAssetIds;
+
+ /// Asset IDs to trash or delete
+ List trashAssetIds;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveGroupDto &&
+ other.duplicateId == duplicateId &&
+ _deepEquality.equals(other.keepAssetIds, keepAssetIds) &&
+ _deepEquality.equals(other.trashAssetIds, trashAssetIds);
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (duplicateId.hashCode) +
+ (keepAssetIds.hashCode) +
+ (trashAssetIds.hashCode);
+
+ @override
+ String toString() => 'DuplicateResolveGroupDto[duplicateId=$duplicateId, keepAssetIds=$keepAssetIds, trashAssetIds=$trashAssetIds]';
+
+ Map toJson() {
+ final json = {};
+ json[r'duplicateId'] = this.duplicateId;
+ json[r'keepAssetIds'] = this.keepAssetIds;
+ json[r'trashAssetIds'] = this.trashAssetIds;
+ return json;
+ }
+
+ /// Returns a new [DuplicateResolveGroupDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static DuplicateResolveGroupDto? fromJson(dynamic value) {
+ upgradeDto(value, "DuplicateResolveGroupDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return DuplicateResolveGroupDto(
+ duplicateId: mapValueOfType(json, r'duplicateId')!,
+ keepAssetIds: json[r'keepAssetIds'] is Iterable
+ ? (json[r'keepAssetIds'] as Iterable).cast().toList(growable: false)
+ : const [],
+ trashAssetIds: json[r'trashAssetIds'] is Iterable
+ ? (json[r'trashAssetIds'] as Iterable).cast().toList(growable: false)
+ : const [],
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveGroupDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = DuplicateResolveGroupDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of DuplicateResolveGroupDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = DuplicateResolveGroupDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'duplicateId',
+ 'keepAssetIds',
+ 'trashAssetIds',
+ };
+}
+
diff --git a/mobile/openapi/lib/model/duplicate_resolve_response_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_response_dto.dart
new file mode 100644
index 0000000000000..6f882da7c275d
--- /dev/null
+++ b/mobile/openapi/lib/model/duplicate_resolve_response_dto.dart
@@ -0,0 +1,180 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DuplicateResolveResponseDto {
+ /// Returns a new [DuplicateResolveResponseDto] instance.
+ DuplicateResolveResponseDto({
+ this.results = const [],
+ required this.status,
+ });
+
+ /// Per-group results of the resolve operation
+ List results;
+
+ /// Overall status of the resolve operation
+ DuplicateResolveResponseDtoStatusEnum status;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveResponseDto &&
+ _deepEquality.equals(other.results, results) &&
+ other.status == status;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (results.hashCode) +
+ (status.hashCode);
+
+ @override
+ String toString() => 'DuplicateResolveResponseDto[results=$results, status=$status]';
+
+ Map toJson() {
+ final json = {};
+ json[r'results'] = this.results;
+ json[r'status'] = this.status;
+ return json;
+ }
+
+ /// Returns a new [DuplicateResolveResponseDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static DuplicateResolveResponseDto? fromJson(dynamic value) {
+ upgradeDto(value, "DuplicateResolveResponseDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return DuplicateResolveResponseDto(
+ results: DuplicateResolveResultDto.listFromJson(json[r'results']),
+ status: DuplicateResolveResponseDtoStatusEnum.fromJson(json[r'status'])!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveResponseDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = DuplicateResolveResponseDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of DuplicateResolveResponseDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = DuplicateResolveResponseDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'results',
+ 'status',
+ };
+}
+
+/// Overall status of the resolve operation
+class DuplicateResolveResponseDtoStatusEnum {
+ /// Instantiate a new enum with the provided [value].
+ const DuplicateResolveResponseDtoStatusEnum._(this.value);
+
+ /// The underlying value of this enum member.
+ final String value;
+
+ @override
+ String toString() => value;
+
+ String toJson() => value;
+
+ static const COMPLETED = DuplicateResolveResponseDtoStatusEnum._(r'COMPLETED');
+
+ /// List of all possible values in this [enum][DuplicateResolveResponseDtoStatusEnum].
+ static const values = [
+ COMPLETED,
+ ];
+
+ static DuplicateResolveResponseDtoStatusEnum? fromJson(dynamic value) => DuplicateResolveResponseDtoStatusEnumTypeTransformer().decode(value);
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveResponseDtoStatusEnum.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+}
+
+/// Transformation class that can [encode] an instance of [DuplicateResolveResponseDtoStatusEnum] to String,
+/// and [decode] dynamic data back to [DuplicateResolveResponseDtoStatusEnum].
+class DuplicateResolveResponseDtoStatusEnumTypeTransformer {
+ factory DuplicateResolveResponseDtoStatusEnumTypeTransformer() => _instance ??= const DuplicateResolveResponseDtoStatusEnumTypeTransformer._();
+
+ const DuplicateResolveResponseDtoStatusEnumTypeTransformer._();
+
+ String encode(DuplicateResolveResponseDtoStatusEnum data) => data.value;
+
+ /// Decodes a [dynamic value][data] to a DuplicateResolveResponseDtoStatusEnum.
+ ///
+ /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+ /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+ /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+ ///
+ /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+ /// and users are still using an old app with the old code.
+ DuplicateResolveResponseDtoStatusEnum? decode(dynamic data, {bool allowNull = true}) {
+ if (data != null) {
+ switch (data) {
+ case r'COMPLETED': return DuplicateResolveResponseDtoStatusEnum.COMPLETED;
+ default:
+ if (!allowNull) {
+ throw ArgumentError('Unknown enum value to decode: $data');
+ }
+ }
+ }
+ return null;
+ }
+
+ /// Singleton [DuplicateResolveResponseDtoStatusEnumTypeTransformer] instance.
+ static DuplicateResolveResponseDtoStatusEnumTypeTransformer? _instance;
+}
+
+
diff --git a/mobile/openapi/lib/model/duplicate_resolve_result_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_result_dto.dart
new file mode 100644
index 0000000000000..984a80f49dc40
--- /dev/null
+++ b/mobile/openapi/lib/model/duplicate_resolve_result_dto.dart
@@ -0,0 +1,201 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DuplicateResolveResultDto {
+ /// Returns a new [DuplicateResolveResultDto] instance.
+ DuplicateResolveResultDto({
+ required this.duplicateId,
+ this.reason,
+ required this.status,
+ });
+
+ /// The duplicate group ID that was processed
+ String duplicateId;
+
+ /// Error reason if status is FAILED
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ String? reason;
+
+ /// Status of the resolve operation for this group
+ DuplicateResolveResultDtoStatusEnum status;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveResultDto &&
+ other.duplicateId == duplicateId &&
+ other.reason == reason &&
+ other.status == status;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (duplicateId.hashCode) +
+ (reason == null ? 0 : reason!.hashCode) +
+ (status.hashCode);
+
+ @override
+ String toString() => 'DuplicateResolveResultDto[duplicateId=$duplicateId, reason=$reason, status=$status]';
+
+ Map toJson() {
+ final json = {};
+ json[r'duplicateId'] = this.duplicateId;
+ if (this.reason != null) {
+ json[r'reason'] = this.reason;
+ } else {
+ // json[r'reason'] = null;
+ }
+ json[r'status'] = this.status;
+ return json;
+ }
+
+ /// Returns a new [DuplicateResolveResultDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static DuplicateResolveResultDto? fromJson(dynamic value) {
+ upgradeDto(value, "DuplicateResolveResultDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return DuplicateResolveResultDto(
+ duplicateId: mapValueOfType(json, r'duplicateId')!,
+ reason: mapValueOfType(json, r'reason'),
+ status: DuplicateResolveResultDtoStatusEnum.fromJson(json[r'status'])!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveResultDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = DuplicateResolveResultDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of DuplicateResolveResultDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = DuplicateResolveResultDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'duplicateId',
+ 'status',
+ };
+}
+
+/// Status of the resolve operation for this group
+class DuplicateResolveResultDtoStatusEnum {
+ /// Instantiate a new enum with the provided [value].
+ const DuplicateResolveResultDtoStatusEnum._(this.value);
+
+ /// The underlying value of this enum member.
+ final String value;
+
+ @override
+ String toString() => value;
+
+ String toJson() => value;
+
+ static const SUCCESS = DuplicateResolveResultDtoStatusEnum._(r'SUCCESS');
+ static const FAILED = DuplicateResolveResultDtoStatusEnum._(r'FAILED');
+
+ /// List of all possible values in this [enum][DuplicateResolveResultDtoStatusEnum].
+ static const values = [
+ SUCCESS,
+ FAILED,
+ ];
+
+ static DuplicateResolveResultDtoStatusEnum? fromJson(dynamic value) => DuplicateResolveResultDtoStatusEnumTypeTransformer().decode(value);
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveResultDtoStatusEnum.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+}
+
+/// Transformation class that can [encode] an instance of [DuplicateResolveResultDtoStatusEnum] to String,
+/// and [decode] dynamic data back to [DuplicateResolveResultDtoStatusEnum].
+class DuplicateResolveResultDtoStatusEnumTypeTransformer {
+ factory DuplicateResolveResultDtoStatusEnumTypeTransformer() => _instance ??= const DuplicateResolveResultDtoStatusEnumTypeTransformer._();
+
+ const DuplicateResolveResultDtoStatusEnumTypeTransformer._();
+
+ String encode(DuplicateResolveResultDtoStatusEnum data) => data.value;
+
+ /// Decodes a [dynamic value][data] to a DuplicateResolveResultDtoStatusEnum.
+ ///
+ /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+ /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+ /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+ ///
+ /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+ /// and users are still using an old app with the old code.
+ DuplicateResolveResultDtoStatusEnum? decode(dynamic data, {bool allowNull = true}) {
+ if (data != null) {
+ switch (data) {
+ case r'SUCCESS': return DuplicateResolveResultDtoStatusEnum.SUCCESS;
+ case r'FAILED': return DuplicateResolveResultDtoStatusEnum.FAILED;
+ default:
+ if (!allowNull) {
+ throw ArgumentError('Unknown enum value to decode: $data');
+ }
+ }
+ }
+ return null;
+ }
+
+ /// Singleton [DuplicateResolveResultDtoStatusEnumTypeTransformer] instance.
+ static DuplicateResolveResultDtoStatusEnumTypeTransformer? _instance;
+}
+
+
diff --git a/mobile/openapi/lib/model/duplicate_resolve_settings_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_settings_dto.dart
new file mode 100644
index 0000000000000..702d643a4ebe7
--- /dev/null
+++ b/mobile/openapi/lib/model/duplicate_resolve_settings_dto.dart
@@ -0,0 +1,154 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DuplicateResolveSettingsDto {
+ /// Returns a new [DuplicateResolveSettingsDto] instance.
+ DuplicateResolveSettingsDto({
+ required this.synchronizeAlbums,
+ required this.synchronizeDescription,
+ required this.synchronizeFavorites,
+ required this.synchronizeLocation,
+ required this.synchronizeRating,
+ required this.synchronizeTags,
+ required this.synchronizeVisibility,
+ });
+
+ /// Synchronize album membership across duplicate group
+ bool synchronizeAlbums;
+
+ /// Synchronize description across duplicate group
+ bool synchronizeDescription;
+
+ /// Synchronize favorite status across duplicate group
+ bool synchronizeFavorites;
+
+ /// Synchronize GPS location across duplicate group
+ bool synchronizeLocation;
+
+ /// Synchronize EXIF rating across duplicate group
+ bool synchronizeRating;
+
+ /// Synchronize tags across duplicate group
+ bool synchronizeTags;
+
+ /// Synchronize visibility (archive/timeline) across duplicate group
+ bool synchronizeVisibility;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveSettingsDto &&
+ other.synchronizeAlbums == synchronizeAlbums &&
+ other.synchronizeDescription == synchronizeDescription &&
+ other.synchronizeFavorites == synchronizeFavorites &&
+ other.synchronizeLocation == synchronizeLocation &&
+ other.synchronizeRating == synchronizeRating &&
+ other.synchronizeTags == synchronizeTags &&
+ other.synchronizeVisibility == synchronizeVisibility;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (synchronizeAlbums.hashCode) +
+ (synchronizeDescription.hashCode) +
+ (synchronizeFavorites.hashCode) +
+ (synchronizeLocation.hashCode) +
+ (synchronizeRating.hashCode) +
+ (synchronizeTags.hashCode) +
+ (synchronizeVisibility.hashCode);
+
+ @override
+ String toString() => 'DuplicateResolveSettingsDto[synchronizeAlbums=$synchronizeAlbums, synchronizeDescription=$synchronizeDescription, synchronizeFavorites=$synchronizeFavorites, synchronizeLocation=$synchronizeLocation, synchronizeRating=$synchronizeRating, synchronizeTags=$synchronizeTags, synchronizeVisibility=$synchronizeVisibility]';
+
+ Map toJson() {
+ final json = {};
+ json[r'synchronizeAlbums'] = this.synchronizeAlbums;
+ json[r'synchronizeDescription'] = this.synchronizeDescription;
+ json[r'synchronizeFavorites'] = this.synchronizeFavorites;
+ json[r'synchronizeLocation'] = this.synchronizeLocation;
+ json[r'synchronizeRating'] = this.synchronizeRating;
+ json[r'synchronizeTags'] = this.synchronizeTags;
+ json[r'synchronizeVisibility'] = this.synchronizeVisibility;
+ return json;
+ }
+
+ /// Returns a new [DuplicateResolveSettingsDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static DuplicateResolveSettingsDto? fromJson(dynamic value) {
+ upgradeDto(value, "DuplicateResolveSettingsDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return DuplicateResolveSettingsDto(
+ synchronizeAlbums: mapValueOfType(json, r'synchronizeAlbums')!,
+ synchronizeDescription: mapValueOfType(json, r'synchronizeDescription')!,
+ synchronizeFavorites: mapValueOfType(json, r'synchronizeFavorites')!,
+ synchronizeLocation: mapValueOfType(json, r'synchronizeLocation')!,
+ synchronizeRating: mapValueOfType(json, r'synchronizeRating')!,
+ synchronizeTags: mapValueOfType(json, r'synchronizeTags')!,
+ synchronizeVisibility: mapValueOfType(json, r'synchronizeVisibility')!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateResolveSettingsDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = DuplicateResolveSettingsDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of DuplicateResolveSettingsDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = DuplicateResolveSettingsDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'synchronizeAlbums',
+ 'synchronizeDescription',
+ 'synchronizeFavorites',
+ 'synchronizeLocation',
+ 'synchronizeRating',
+ 'synchronizeTags',
+ 'synchronizeVisibility',
+ };
+}
+
diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart
index 6c85dc8013713..f0ddbb4fdd8ea 100644
--- a/mobile/openapi/lib/model/duplicate_response_dto.dart
+++ b/mobile/openapi/lib/model/duplicate_response_dto.dart
@@ -15,6 +15,7 @@ class DuplicateResponseDto {
DuplicateResponseDto({
this.assets = const [],
required this.duplicateId,
+ this.suggestedKeepAssetIds = const [],
});
/// Duplicate assets
@@ -23,24 +24,30 @@ class DuplicateResponseDto {
/// Duplicate group ID
String duplicateId;
+ /// Suggested asset IDs to keep based on file size and EXIF data
+ List suggestedKeepAssetIds;
+
@override
bool operator ==(Object other) => identical(this, other) || other is DuplicateResponseDto &&
_deepEquality.equals(other.assets, assets) &&
- other.duplicateId == duplicateId;
+ other.duplicateId == duplicateId &&
+ _deepEquality.equals(other.suggestedKeepAssetIds, suggestedKeepAssetIds);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assets.hashCode) +
- (duplicateId.hashCode);
+ (duplicateId.hashCode) +
+ (suggestedKeepAssetIds.hashCode);
@override
- String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId]';
+ String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId, suggestedKeepAssetIds=$suggestedKeepAssetIds]';
Map toJson() {
final json = {};
json[r'assets'] = this.assets;
json[r'duplicateId'] = this.duplicateId;
+ json[r'suggestedKeepAssetIds'] = this.suggestedKeepAssetIds;
return json;
}
@@ -55,6 +62,9 @@ class DuplicateResponseDto {
return DuplicateResponseDto(
assets: AssetResponseDto.listFromJson(json[r'assets']),
duplicateId: mapValueOfType(json, r'duplicateId')!,
+ suggestedKeepAssetIds: json[r'suggestedKeepAssetIds'] is Iterable
+ ? (json[r'suggestedKeepAssetIds'] as Iterable).cast().toList(growable: false)
+ : const [],
);
}
return null;
@@ -104,6 +114,7 @@ class DuplicateResponseDto {
static const requiredKeys = {
'assets',
'duplicateId',
+ 'suggestedKeepAssetIds',
};
}
diff --git a/mobile/openapi/lib/model/duplicate_stack_dto.dart b/mobile/openapi/lib/model/duplicate_stack_dto.dart
new file mode 100644
index 0000000000000..df514d766c49a
--- /dev/null
+++ b/mobile/openapi/lib/model/duplicate_stack_dto.dart
@@ -0,0 +1,128 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class DuplicateStackDto {
+ /// Returns a new [DuplicateStackDto] instance.
+ DuplicateStackDto({
+ this.assetIds = const [],
+ required this.duplicateId,
+ this.primaryAssetId,
+ });
+
+ /// Asset IDs to stack (minimum 2). All must be members of the duplicate group.
+ List assetIds;
+
+ String duplicateId;
+
+ /// Optional primary asset ID. Must be in assetIds if provided. If omitted, first asset becomes primary.
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ String? primaryAssetId;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is DuplicateStackDto &&
+ _deepEquality.equals(other.assetIds, assetIds) &&
+ other.duplicateId == duplicateId &&
+ other.primaryAssetId == primaryAssetId;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (assetIds.hashCode) +
+ (duplicateId.hashCode) +
+ (primaryAssetId == null ? 0 : primaryAssetId!.hashCode);
+
+ @override
+ String toString() => 'DuplicateStackDto[assetIds=$assetIds, duplicateId=$duplicateId, primaryAssetId=$primaryAssetId]';
+
+ Map toJson() {
+ final json = {};
+ json[r'assetIds'] = this.assetIds;
+ json[r'duplicateId'] = this.duplicateId;
+ if (this.primaryAssetId != null) {
+ json[r'primaryAssetId'] = this.primaryAssetId;
+ } else {
+ // json[r'primaryAssetId'] = null;
+ }
+ return json;
+ }
+
+ /// Returns a new [DuplicateStackDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static DuplicateStackDto? fromJson(dynamic value) {
+ upgradeDto(value, "DuplicateStackDto");
+ if (value is Map) {
+ final json = value.cast();
+
+ return DuplicateStackDto(
+ assetIds: json[r'assetIds'] is Iterable
+ ? (json[r'assetIds'] as Iterable).cast().toList(growable: false)
+ : const [],
+ duplicateId: mapValueOfType(json, r'duplicateId')!,
+ primaryAssetId: mapValueOfType(json, r'primaryAssetId'),
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = DuplicateStackDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = DuplicateStackDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of DuplicateStackDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = DuplicateStackDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'assetIds',
+ 'duplicateId',
+ };
+}
+
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 7f85bbc1cfe0a..392ca4287faed 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -5270,6 +5270,118 @@
"x-immich-state": "Stable"
}
},
+ "/duplicates/resolve": {
+ "post": {
+ "description": "Resolve duplicate groups by synchronizing metadata across assets and optionally deleting/trashing duplicates.",
+ "operationId": "resolveDuplicates",
+ "parameters": [],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DuplicateResolveDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DuplicateResolveResponseDto"
+ }
+ }
+ },
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "summary": "Resolve duplicate groups",
+ "tags": [
+ "Duplicates"
+ ],
+ "x-immich-history": [
+ {
+ "version": "v1",
+ "state": "Added"
+ },
+ {
+ "version": "v1",
+ "state": "Beta"
+ }
+ ],
+ "x-immich-permission": "duplicate.delete",
+ "x-immich-state": "Beta"
+ }
+ },
+ "/duplicates/stack": {
+ "post": {
+ "description": "Create a stack from assets in a duplicate group and clear their duplicate membership.",
+ "operationId": "stackDuplicates",
+ "parameters": [],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DuplicateStackDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StackResponseDto"
+ }
+ }
+ },
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "summary": "Stack duplicates",
+ "tags": [
+ "Duplicates"
+ ],
+ "x-immich-history": [
+ {
+ "version": "v1",
+ "state": "Added"
+ },
+ {
+ "version": "v1",
+ "state": "Beta"
+ }
+ ],
+ "x-immich-permission": "asset.update",
+ "x-immich-state": "Beta"
+ }
+ },
"/duplicates/{id}": {
"delete": {
"description": "Delete a single duplicate asset specified by its ID.",
@@ -17695,6 +17807,151 @@
],
"type": "object"
},
+ "DuplicateResolveDto": {
+ "properties": {
+ "groups": {
+ "description": "List of duplicate groups to resolve",
+ "items": {
+ "$ref": "#/components/schemas/DuplicateResolveGroupDto"
+ },
+ "minItems": 1,
+ "type": "array"
+ },
+ "settings": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/DuplicateResolveSettingsDto"
+ }
+ ],
+ "description": "Settings for synchronization behavior"
+ }
+ },
+ "required": [
+ "groups",
+ "settings"
+ ],
+ "type": "object"
+ },
+ "DuplicateResolveGroupDto": {
+ "properties": {
+ "duplicateId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "keepAssetIds": {
+ "description": "Asset IDs to keep (will have duplicateId cleared)",
+ "items": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "trashAssetIds": {
+ "description": "Asset IDs to trash or delete",
+ "items": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "duplicateId",
+ "keepAssetIds",
+ "trashAssetIds"
+ ],
+ "type": "object"
+ },
+ "DuplicateResolveResponseDto": {
+ "properties": {
+ "results": {
+ "description": "Per-group results of the resolve operation",
+ "items": {
+ "$ref": "#/components/schemas/DuplicateResolveResultDto"
+ },
+ "type": "array"
+ },
+ "status": {
+ "description": "Overall status of the resolve operation",
+ "enum": [
+ "COMPLETED"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "results",
+ "status"
+ ],
+ "type": "object"
+ },
+ "DuplicateResolveResultDto": {
+ "properties": {
+ "duplicateId": {
+ "description": "The duplicate group ID that was processed",
+ "type": "string"
+ },
+ "reason": {
+ "description": "Error reason if status is FAILED",
+ "type": "string"
+ },
+ "status": {
+ "description": "Status of the resolve operation for this group",
+ "enum": [
+ "SUCCESS",
+ "FAILED"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "duplicateId",
+ "status"
+ ],
+ "type": "object"
+ },
+ "DuplicateResolveSettingsDto": {
+ "properties": {
+ "synchronizeAlbums": {
+ "description": "Synchronize album membership across duplicate group",
+ "type": "boolean"
+ },
+ "synchronizeDescription": {
+ "description": "Synchronize description across duplicate group",
+ "type": "boolean"
+ },
+ "synchronizeFavorites": {
+ "description": "Synchronize favorite status across duplicate group",
+ "type": "boolean"
+ },
+ "synchronizeLocation": {
+ "description": "Synchronize GPS location across duplicate group",
+ "type": "boolean"
+ },
+ "synchronizeRating": {
+ "description": "Synchronize EXIF rating across duplicate group",
+ "type": "boolean"
+ },
+ "synchronizeTags": {
+ "description": "Synchronize tags across duplicate group",
+ "type": "boolean"
+ },
+ "synchronizeVisibility": {
+ "description": "Synchronize visibility (archive/timeline) across duplicate group",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "synchronizeAlbums",
+ "synchronizeDescription",
+ "synchronizeFavorites",
+ "synchronizeLocation",
+ "synchronizeRating",
+ "synchronizeTags",
+ "synchronizeVisibility"
+ ],
+ "type": "object"
+ },
"DuplicateResponseDto": {
"properties": {
"assets": {
@@ -17707,10 +17964,45 @@
"duplicateId": {
"description": "Duplicate group ID",
"type": "string"
+ },
+ "suggestedKeepAssetIds": {
+ "description": "Suggested asset IDs to keep based on file size and EXIF data",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
}
},
"required": [
"assets",
+ "duplicateId",
+ "suggestedKeepAssetIds"
+ ],
+ "type": "object"
+ },
+ "DuplicateStackDto": {
+ "properties": {
+ "assetIds": {
+ "description": "Asset IDs to stack (minimum 2). All must be members of the duplicate group.",
+ "items": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "minItems": 2,
+ "type": "array"
+ },
+ "duplicateId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "primaryAssetId": {
+ "description": "Optional primary asset ID. Must be in assetIds if provided. If omitted, first asset becomes primary.",
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "assetIds",
"duplicateId"
],
"type": "object"
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index d8c960a39322e..9af66b9f91762 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1161,6 +1161,66 @@ export type DuplicateResponseDto = {
assets: AssetResponseDto[];
/** Duplicate group ID */
duplicateId: string;
+ /** Suggested asset IDs to keep based on file size and EXIF data */
+ suggestedKeepAssetIds: string[];
+};
+export type DuplicateResolveGroupDto = {
+ duplicateId: string;
+ /** Asset IDs to keep (will have duplicateId cleared) */
+ keepAssetIds: string[];
+ /** Asset IDs to trash or delete */
+ trashAssetIds: string[];
+};
+export type DuplicateResolveSettingsDto = {
+ /** Synchronize album membership across duplicate group */
+ synchronizeAlbums: boolean;
+ /** Synchronize description across duplicate group */
+ synchronizeDescription: boolean;
+ /** Synchronize favorite status across duplicate group */
+ synchronizeFavorites: boolean;
+ /** Synchronize GPS location across duplicate group */
+ synchronizeLocation: boolean;
+ /** Synchronize EXIF rating across duplicate group */
+ synchronizeRating: boolean;
+ /** Synchronize tags across duplicate group */
+ synchronizeTags: boolean;
+ /** Synchronize visibility (archive/timeline) across duplicate group */
+ synchronizeVisibility: boolean;
+};
+export type DuplicateResolveDto = {
+ /** List of duplicate groups to resolve */
+ groups: DuplicateResolveGroupDto[];
+ /** Settings for synchronization behavior */
+ settings: DuplicateResolveSettingsDto;
+};
+export type DuplicateResolveResultDto = {
+ /** The duplicate group ID that was processed */
+ duplicateId: string;
+ /** Error reason if status is FAILED */
+ reason?: string;
+ /** Status of the resolve operation for this group */
+ status: Status;
+};
+export type DuplicateResolveResponseDto = {
+ /** Per-group results of the resolve operation */
+ results: DuplicateResolveResultDto[];
+ /** Overall status of the resolve operation */
+ status: Status2;
+};
+export type DuplicateStackDto = {
+ /** Asset IDs to stack (minimum 2). All must be members of the duplicate group. */
+ assetIds: string[];
+ duplicateId: string;
+ /** Optional primary asset ID. Must be in assetIds if provided. If omitted, first asset becomes primary. */
+ primaryAssetId?: string;
+};
+export type StackResponseDto = {
+ /** Stack assets */
+ assets: AssetResponseDto[];
+ /** Stack ID */
+ id: string;
+ /** Primary asset ID */
+ primaryAssetId: string;
};
export type PersonResponseDto = {
/** Person date of birth */
@@ -2311,14 +2371,6 @@ export type AssetIdsResponseDto = {
/** Whether operation succeeded */
success: boolean;
};
-export type StackResponseDto = {
- /** Stack assets */
- assets: AssetResponseDto[];
- /** Stack ID */
- id: string;
- /** Primary asset ID */
- primaryAssetId: string;
-};
export type StackCreateDto = {
/** Asset IDs (first becomes primary, min 2) */
assetIds: string[];
@@ -4487,6 +4539,36 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
+/**
+ * Resolve duplicate groups
+ */
+export function resolveDuplicates({ duplicateResolveDto }: {
+ duplicateResolveDto: DuplicateResolveDto;
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: DuplicateResolveResponseDto;
+ }>("/duplicates/resolve", oazapfts.json({
+ ...opts,
+ method: "POST",
+ body: duplicateResolveDto
+ })));
+}
+/**
+ * Stack duplicates
+ */
+export function stackDuplicates({ duplicateStackDto }: {
+ duplicateStackDto: DuplicateStackDto;
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 201;
+ data: StackResponseDto;
+ }>("/duplicates/stack", oazapfts.json({
+ ...opts,
+ method: "POST",
+ body: duplicateStackDto
+ })));
+}
/**
* Delete a duplicate
*/
@@ -7030,6 +7112,13 @@ export enum AssetMediaSize {
Preview = "preview",
Thumbnail = "thumbnail"
}
+export enum Status {
+ Success = "SUCCESS",
+ Failed = "FAILED"
+}
+export enum Status2 {
+ Completed = "COMPLETED"
+}
export enum ManualJobName {
PersonCleanup = "person-cleanup",
TagCleanup = "tag-cleanup",
diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts
index e8c8e5ef80069..a42169e4938c8 100644
--- a/server/src/controllers/duplicate.controller.ts
+++ b/server/src/controllers/duplicate.controller.ts
@@ -1,9 +1,15 @@
-import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
+import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
-import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
+import {
+ DuplicateResolveDto,
+ DuplicateResolveResponseDto,
+ DuplicateResponseDto,
+ DuplicateStackDto,
+} from 'src/dtos/duplicate.dto';
+import { StackResponseDto } from 'src/dtos/stack.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { DuplicateService } from 'src/services/duplicate.service';
@@ -48,4 +54,32 @@ export class DuplicateController {
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise {
return this.service.delete(auth, id);
}
+
+ @Post('resolve')
+ @HttpCode(HttpStatus.OK)
+ @Authenticated({ permission: Permission.DuplicateDelete })
+ @Endpoint({
+ summary: 'Resolve duplicate groups',
+ description:
+ 'Resolve duplicate groups by synchronizing metadata across assets and optionally deleting/trashing duplicates.',
+ history: new HistoryBuilder().added('v1').beta('v1'),
+ })
+ resolveDuplicates(
+ @Auth() auth: AuthDto,
+ @Body() dto: DuplicateResolveDto,
+ ): Promise {
+ return this.service.resolve(auth, dto);
+ }
+
+ @Post('stack')
+ @HttpCode(HttpStatus.CREATED)
+ @Authenticated({ permission: Permission.AssetUpdate })
+ @Endpoint({
+ summary: 'Stack duplicates',
+ description: 'Create a stack from assets in a duplicate group and clear their duplicate membership.',
+ history: new HistoryBuilder().added('v1').beta('v1'),
+ })
+ stackDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateStackDto): Promise {
+ return this.service.stack(auth, dto);
+ }
}
diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts
index 9cd9147ec5ca7..3f2731da82906 100644
--- a/server/src/dtos/duplicate.dto.ts
+++ b/server/src/dtos/duplicate.dto.ts
@@ -1,9 +1,128 @@
import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { ArrayMinSize, IsBoolean, ValidateNested } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
+import { ValidateUUID } from 'src/validation';
export class DuplicateResponseDto {
@ApiProperty({ description: 'Duplicate group ID' })
duplicateId!: string;
@ApiProperty({ description: 'Duplicate assets' })
assets!: AssetResponseDto[];
+
+ @ApiProperty({
+ description: 'Suggested asset IDs to keep based on file size and EXIF data',
+ isArray: true,
+ type: String,
+ })
+ suggestedKeepAssetIds!: string[];
+}
+
+// Resolve endpoint DTOs
+
+export class DuplicateResolveSettingsDto {
+ @ApiProperty({ type: Boolean, description: 'Synchronize album membership across duplicate group' })
+ @IsBoolean()
+ synchronizeAlbums!: boolean;
+
+ @ApiProperty({ type: Boolean, description: 'Synchronize visibility (archive/timeline) across duplicate group' })
+ @IsBoolean()
+ synchronizeVisibility!: boolean;
+
+ @ApiProperty({ type: Boolean, description: 'Synchronize favorite status across duplicate group' })
+ @IsBoolean()
+ synchronizeFavorites!: boolean;
+
+ @ApiProperty({ type: Boolean, description: 'Synchronize EXIF rating across duplicate group' })
+ @IsBoolean()
+ synchronizeRating!: boolean;
+
+ @ApiProperty({ type: Boolean, description: 'Synchronize description across duplicate group' })
+ @IsBoolean()
+ synchronizeDescription!: boolean;
+
+ @ApiProperty({ type: Boolean, description: 'Synchronize GPS location across duplicate group' })
+ @IsBoolean()
+ synchronizeLocation!: boolean;
+
+ @ApiProperty({ type: Boolean, description: 'Synchronize tags across duplicate group' })
+ @IsBoolean()
+ synchronizeTags!: boolean;
+}
+
+export class DuplicateResolveGroupDto {
+ @ValidateUUID()
+ duplicateId!: string;
+
+ @ApiProperty({ isArray: true, type: String, description: 'Asset IDs to keep (will have duplicateId cleared)' })
+ @ValidateUUID({ each: true })
+ keepAssetIds!: string[];
+
+ @ApiProperty({ isArray: true, type: String, description: 'Asset IDs to trash or delete' })
+ @ValidateUUID({ each: true })
+ trashAssetIds!: string[];
+}
+
+export class DuplicateResolveDto {
+ @ApiProperty({ type: [DuplicateResolveGroupDto], description: 'List of duplicate groups to resolve' })
+ @ValidateNested({ each: true })
+ @Type(() => DuplicateResolveGroupDto)
+ @ArrayMinSize(1)
+ groups!: DuplicateResolveGroupDto[];
+
+ @ApiProperty({ type: DuplicateResolveSettingsDto, description: 'Settings for synchronization behavior' })
+ @ValidateNested()
+ @Type(() => DuplicateResolveSettingsDto)
+ settings!: DuplicateResolveSettingsDto;
+}
+
+export enum DuplicateResolveStatus {
+ Success = 'SUCCESS',
+ Failed = 'FAILED',
+}
+
+export class DuplicateResolveResultDto {
+ @ApiProperty({ description: 'The duplicate group ID that was processed' })
+ duplicateId!: string;
+
+ @ApiProperty({ enum: DuplicateResolveStatus, description: 'Status of the resolve operation for this group' })
+ status!: DuplicateResolveStatus;
+
+ @ApiProperty({ required: false, description: 'Error reason if status is FAILED' })
+ reason?: string;
+}
+
+export enum DuplicateResolveBatchStatus {
+ Completed = 'COMPLETED',
+}
+
+export class DuplicateResolveResponseDto {
+ @ApiProperty({ enum: DuplicateResolveBatchStatus, description: 'Overall status of the resolve operation' })
+ status!: DuplicateResolveBatchStatus;
+
+ @ApiProperty({ type: [DuplicateResolveResultDto], description: 'Per-group results of the resolve operation' })
+ results!: DuplicateResolveResultDto[];
+}
+
+// Stack endpoint DTOs
+
+export class DuplicateStackDto {
+ @ValidateUUID()
+ duplicateId!: string;
+
+ @ApiProperty({
+ isArray: true,
+ type: String,
+ description: 'Asset IDs to stack (minimum 2). All must be members of the duplicate group.',
+ })
+ @ValidateUUID({ each: true })
+ @ArrayMinSize(2)
+ assetIds!: string[];
+
+ @ApiProperty({
+ required: false,
+ description: 'Optional primary asset ID. Must be in assetIds if provided. If omitted, first asset becomes primary.',
+ })
+ @ValidateUUID({ optional: true })
+ primaryAssetId?: string;
}
diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql
index f62e769a17bab..45edf4cc8d420 100644
--- a/server/src/queries/album.repository.sql
+++ b/server/src/queries/album.repository.sql
@@ -164,6 +164,28 @@ order by
"album"."createdAt" desc,
"album"."createdAt" desc
+-- AlbumRepository.getByAssetIds
+select
+ "album"."id",
+ "album_asset"."assetId"
+from
+ "album"
+ inner join "album_asset" on "album_asset"."albumId" = "album"."id"
+where
+ (
+ "album"."ownerId" = $1
+ or exists (
+ select
+ from
+ "album_user"
+ where
+ "album_user"."albumId" = "album"."id"
+ and "album_user"."userId" = $2
+ )
+ )
+ and "album_asset"."assetId" in ($3)
+ and "album"."deletedAt" is null
+
-- AlbumRepository.getMetadataForIds
select
"album_asset"."albumId" as "albumId",
diff --git a/server/src/queries/duplicate.repository.sql b/server/src/queries/duplicate.repository.sql
index 3f718f84c2001..68ef32c20be4c 100644
--- a/server/src/queries/duplicate.repository.sql
+++ b/server/src/queries/duplicate.repository.sql
@@ -15,7 +15,26 @@ with
inner join lateral (
select
"asset".*,
- "asset_exif" as "exifInfo"
+ to_json("asset_exif") as "exifInfo",
+ (
+ select
+ coalesce(json_agg(agg), '[]')
+ from
+ (
+ select
+ "tag"."id",
+ "tag"."value",
+ "tag"."createdAt",
+ "tag"."updatedAt",
+ "tag"."color",
+ "tag"."parentId"
+ from
+ "tag"
+ inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
+ where
+ "tag_asset"."assetId" = "asset"."id"
+ ) as agg
+ ) as "tags"
from
"asset_exif"
where
@@ -29,36 +48,85 @@ with
and "asset"."stackId" is null
group by
"asset"."duplicateId"
- ),
- "unique" as (
- select
- "duplicateId"
- from
- "duplicates"
- where
- json_array_length("assets") = $2
- ),
- "removed_unique" as (
- update "asset"
- set
- "duplicateId" = $3
- from
- "unique"
- where
- "asset"."duplicateId" = "unique"."duplicateId"
)
select
*
from
"duplicates"
where
- not exists (
+ json_array_length("assets") > $2
+
+-- DuplicateRepository.cleanupSingletonGroups
+with
+ "singletons" as (
select
+ "duplicateId"
from
- "unique"
+ "asset"
where
- "unique"."duplicateId" = "duplicates"."duplicateId"
+ "ownerId" = $1::uuid
+ and "duplicateId" is not null
+ and "deletedAt" is null
+ and "stackId" is null
+ group by
+ "duplicateId"
+ having
+ count("id") = $2
)
+update "asset"
+set
+ "duplicateId" = $3
+from
+ "singletons"
+where
+ "asset"."duplicateId" = "singletons"."duplicateId"
+
+-- DuplicateRepository.getByIdForUser
+select
+ "asset"."duplicateId",
+ json_agg(
+ "asset2"
+ order by
+ "asset"."localDateTime" asc
+ ) as "assets"
+from
+ "asset"
+ inner join lateral (
+ select
+ "asset".*,
+ to_json("asset_exif") as "exifInfo",
+ (
+ select
+ coalesce(json_agg(agg), '[]')
+ from
+ (
+ select
+ "tag"."id",
+ "tag"."value",
+ "tag"."createdAt",
+ "tag"."updatedAt",
+ "tag"."color",
+ "tag"."parentId"
+ from
+ "tag"
+ inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
+ where
+ "tag_asset"."assetId" = "asset"."id"
+ ) as agg
+ ) as "tags"
+ from
+ "asset_exif"
+ where
+ "asset_exif"."assetId" = "asset"."id"
+ ) as "asset2" on true
+where
+ "asset"."visibility" in ('archive', 'timeline')
+ and "asset"."ownerId" = $1::uuid
+ and "asset"."duplicateId" = $2::uuid
+ and "asset"."deletedAt" is null
+ and "asset"."stackId" is null
+group by
+ "asset"."duplicateId"
-- DuplicateRepository.delete
update "asset"
diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts
index 100ab908c0880..56b57bc33d606 100644
--- a/server/src/repositories/album.repository.ts
+++ b/server/src/repositories/album.repository.ts
@@ -113,6 +113,44 @@ export class AlbumRepository {
.execute();
}
+ @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
+ @ChunkedSet({ paramIndex: 1 })
+ async getByAssetIds(ownerId: string, assetIds: string[]): Promise