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> { + if (assetIds.length === 0) { + return new Map(); + } + + const results = await this.db + .selectFrom('album') + .select('album.id') + .innerJoin('album_asset', 'album_asset.albumId', 'album.id') + .where((eb) => + eb.or([ + eb('album.ownerId', '=', ownerId), + eb.exists( + eb + .selectFrom('album_user') + .whereRef('album_user.albumId', '=', 'album.id') + .where('album_user.userId', '=', ownerId), + ), + ]), + ) + .where('album_asset.assetId', 'in', assetIds) + .where('album.deletedAt', 'is', null) + .select('album_asset.assetId') + .execute(); + + // Group by assetId + const map = new Map(); + for (const row of results) { + const existing = map.get(row.assetId) ?? []; + existing.push(row.id); + map.set(row.assetId, existing); + } + + return map; + } + @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() async getMetadataForIds(ids: string[]): Promise { @@ -326,7 +364,12 @@ export class AlbumRepository { if (values.length === 0) { return; } - await this.db.insertInto('album_asset').values(values).execute(); + await this.db + .insertInto('album_asset') + .values(values) + // Allow idempotent album sync without failing on existing album memberships. + .onConflict((oc) => oc.columns(['albumId', 'assetId']).doNothing()) + .execute(); } /** diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index 95ccbea63d69e..b2625183f84c1 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { Kysely, NotNull, sql } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, VectorIndex } from 'src/enum'; @@ -8,6 +10,9 @@ import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database'; +// Maximum number of candidate duplicates to return from vector search +const DUPLICATE_SEARCH_LIMIT = 64; + interface DuplicateSearch { assetId: string; embedding: string; @@ -34,12 +39,25 @@ export class DuplicateRepository { qb .selectFrom('asset') .$call(withDefaultVisibility) + // Use innerJoinLateral to build a composite object per asset that includes + // exifInfo and tags. This "asset2" object is then aggregated via jsonAgg. + // Tags must be included here (not via separate joins) so they appear in the + // final MapAsset[] output - needed for tag synchronization during resolution. .innerJoinLateral( (qb) => qb .selectFrom('asset_exif') .selectAll('asset') - .select((eb) => eb.table('asset_exif').as('exifInfo')) + .select((eb) => eb.fn.toJson('asset_exif').as('exifInfo')) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('tag') + .select(columns.tag) + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('tag_asset.assetId', '=', 'asset.id'), + ).as('tags'), + ) .whereRef('asset_exif.assetId', '=', 'asset.id') .as('asset2'), (join) => join.onTrue(), @@ -55,29 +73,86 @@ export class DuplicateRepository { .where('asset.stackId', 'is', null) .groupBy('asset.duplicateId'), ) - .with('unique', (qb) => - qb - .selectFrom('duplicates') - .select('duplicateId') - .where((eb) => eb(eb.fn('json_array_length', ['assets']), '=', 1)), - ) - .with('removed_unique', (qb) => - qb - .updateTable('asset') - .set({ duplicateId: null }) - .from('unique') - .whereRef('asset.duplicateId', '=', 'unique.duplicateId'), - ) .selectFrom('duplicates') .selectAll() - // TODO: compare with filtering by json_array_length > 1 - .where(({ not, exists }) => - not(exists((eb) => eb.selectFrom('unique').whereRef('unique.duplicateId', '=', 'duplicates.duplicateId'))), - ) + // Filter out singleton groups (only 1 asset) directly in the query + .where((eb) => eb(eb.fn('json_array_length', ['assets']), '>', 1)) .execute() ); } + @GenerateSql({ params: [DummyValue.UUID] }) + async cleanupSingletonGroups(userId: string): Promise { + // Remove duplicateId from assets that are the only member of their duplicate group + await this.db + .with('singletons', (qb) => + qb + .selectFrom('asset') + .select('duplicateId') + .where('ownerId', '=', asUuid(userId)) + .where('duplicateId', 'is not', null) + .$narrowType<{ duplicateId: NotNull }>() + .where('deletedAt', 'is', null) + .where('stackId', 'is', null) + .groupBy('duplicateId') + .having((eb) => eb.fn.count('id'), '=', 1), + ) + .updateTable('asset') + .set({ duplicateId: null }) + .from('singletons') + .whereRef('asset.duplicateId', '=', 'singletons.duplicateId') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async getByIdForUser( + userId: string, + duplicateId: string, + ): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> { + const result = await this.db + .selectFrom('asset') + .$call(withDefaultVisibility) + // Use innerJoinLateral to build a composite object per asset that includes + // exifInfo and tags. This "asset2" object is then aggregated via jsonAgg. + // Tags must be included here (not via separate joins) so they appear in the + // final MapAsset[] output - needed for tag synchronization during resolution. + .innerJoinLateral( + (qb) => + qb + .selectFrom('asset_exif') + .selectAll('asset') + .select((eb) => eb.fn.toJson('asset_exif').as('exifInfo')) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('tag') + .select(columns.tag) + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('tag_asset.assetId', '=', 'asset.id'), + ).as('tags'), + ) + .whereRef('asset_exif.assetId', '=', 'asset.id') + .as('asset2'), + (join) => join.onTrue(), + ) + .select('asset.duplicateId') + .select((eb) => + eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets'), + ) + .where('asset.ownerId', '=', asUuid(userId)) + .where('asset.duplicateId', '=', asUuid(duplicateId)) + .where('asset.deletedAt', 'is', null) + .where('asset.stackId', 'is', null) + .groupBy('asset.duplicateId') + .executeTakeFirst(); + + if (!result || !result.duplicateId) { + return undefined; + } + + return { duplicateId: result.duplicateId, assets: result.assets }; + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async delete(userId: string, id: string): Promise { await this.db @@ -134,7 +209,7 @@ export class DuplicateRepository { .where('asset.id', '!=', asUuid(assetId)) .where('asset.stackId', 'is', null) .orderBy('distance') - .limit(64), + .limit(DUPLICATE_SEARCH_LIMIT), ) .selectFrom('cte') .selectAll() diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index e5ac9f82ba3cf..890bac1e3689a 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -38,6 +38,7 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { + mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue(); mocks.duplicateRepository.getAll.mockResolvedValue([ { duplicateId: 'duplicate-id', @@ -51,9 +52,32 @@ describe(SearchService.name, () => { expect.objectContaining({ id: assetStub.image.id }), expect.objectContaining({ id: assetStub.image.id }), ], + suggestedKeepAssetIds: [assetStub.image.id], }, ]); }); + + it('should return suggestedKeepAssetIds based on file size', async () => { + const smallAsset = { + ...assetStub.image, + id: 'small-asset', + exifInfo: { ...assetStub.image.exifInfo, fileSizeInByte: 1000 }, + }; + const largeAsset = { + ...assetStub.image, + id: 'large-asset', + exifInfo: { ...assetStub.image.exifInfo, fileSizeInByte: 5000 }, + }; + mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue(); + mocks.duplicateRepository.getAll.mockResolvedValue([ + { + duplicateId: 'duplicate-id', + assets: [smallAsset, largeAsset], + }, + ]); + const result = await sut.getDuplicates(authStub.admin); + expect(result[0].suggestedKeepAssetIds).toEqual(['large-asset']); + }); }); describe('handleQueueSearchDuplicates', () => { @@ -129,6 +153,231 @@ describe(SearchService.name, () => { }); }); + describe('resolve', () => { + it('should return COMPLETED status even with all failures', async () => { + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'fake-id', keepAssetIds: [], trashAssetIds: [] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.status).toBe('COMPLETED'); + expect(result.results).toHaveLength(1); + expect(result.results[0].status).toBe('FAILED'); + }); + + it('should handle mixed success and failure', async () => { + // First group: missing group (will fail) + mocks.duplicateRepository.getByIdForUser.mockResolvedValueOnce(undefined); + + // Second group: invalid inputs (will also fail) + mocks.duplicateRepository.getByIdForUser.mockResolvedValueOnce({ + duplicateId: 'group-2', + assets: [{ ...assetStub.image, id: 'asset-1' }], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [ + { duplicateId: 'fake-id', keepAssetIds: [], trashAssetIds: [] }, + { duplicateId: 'group-2', keepAssetIds: ['non-member-id'], trashAssetIds: [] }, + ], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.status).toBe('COMPLETED'); + expect(result.results).toHaveLength(2); + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[1].status).toBe('FAILED'); + }); + + it('should catch and report errors in resolveGroup', async () => { + mocks.duplicateRepository.getByIdForUser.mockRejectedValue(new Error('Database error')); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.status).toBe('COMPLETED'); + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('Database error'); + }); + }); + + describe('resolveGroup (via resolve)', () => { + it('should fail if duplicate group not found', async () => { + mocks.duplicateRepository.getByIdForUser.mockResolvedValue(undefined); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('not found or access denied'); + }); + + it('should fail if keepAssetIds contains non-member', async () => { + mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + duplicateId: 'group-1', + assets: [{ ...assetStub.image, id: 'asset-1' }], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-999'], trashAssetIds: [] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('not a member of duplicate group'); + }); + + it('should fail if trashAssetIds contains non-member', async () => { + mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + duplicateId: 'group-1', + assets: [{ ...assetStub.image, id: 'asset-1' }], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: ['asset-999'] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('not a member of duplicate group'); + }); + + it('should fail if keepAssetIds and trashAssetIds overlap', async () => { + mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + duplicateId: 'group-1', + assets: [ + { ...assetStub.image, id: 'asset-1' }, + { ...assetStub.image, id: 'asset-2' }, + ], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-1'] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('disjoint'); + }); + + it('should fail if keepAssetIds and trashAssetIds do not cover all assets', async () => { + mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + duplicateId: 'group-1', + assets: [ + { ...assetStub.image, id: 'asset-1' }, + { ...assetStub.image, id: 'asset-2' }, + { ...assetStub.image, id: 'asset-3' }, + ], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-2'] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('must cover all assets'); + }); + + it('should fail if partial trash without keepers', async () => { + mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + duplicateId: 'group-1', + assets: [ + { ...assetStub.image, id: 'asset-1' }, + { ...assetStub.image, id: 'asset-2' }, + ], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: ['asset-1'] }], + settings: { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, + }, + }); + + expect(result.results[0].status).toBe('FAILED'); + expect(result.results[0].reason).toContain('must cover all assets'); + }); + + // NOTE: The following integration-style tests are covered by E2E tests instead + // to avoid complex mock setup. The validation and error-handling logic above + // is thoroughly unit tested. + }); + describe('handleSearchDuplicates', () => { beforeEach(() => { mocks.systemMetadata.get.mockResolvedValue({ diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 618754ff743f9..17e45bdfc2519 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,24 +1,47 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; -import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; +import { + DuplicateResolveBatchStatus, + DuplicateResolveDto, + DuplicateResolveGroupDto, + DuplicateResolveResponseDto, + DuplicateResolveResultDto, + DuplicateResolveSettingsDto, + DuplicateResolveStatus, + DuplicateResponseDto, + DuplicateStackDto, +} from 'src/dtos/duplicate.dto'; +import { mapStack, StackResponseDto } from 'src/dtos/stack.dto'; +import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate'; +import { computeResolvePolicy } from 'src/utils/duplicate-resolve'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; +// Fields that are stored in the exif table rather than the asset table +const EXIF_FIELDS = ['latitude', 'longitude', 'rating', 'description'] as const; + @Injectable() export class DuplicateService extends BaseService { async getDuplicates(auth: AuthDto): Promise { + // Clean up singleton groups (assets that are the only member of their duplicate group) + await this.duplicateRepository.cleanupSingletonGroups(auth.user.id); + const duplicates = await this.duplicateRepository.getAll(auth.user.id); - return duplicates.map(({ duplicateId, assets }) => ({ - duplicateId, - assets: assets.map((asset) => mapAsset(asset, { auth })), - })); + return duplicates.map(({ duplicateId, assets }) => { + const mappedAssets = assets.map((asset) => mapAsset(asset, { auth })); + return { + duplicateId, + assets: mappedAssets, + suggestedKeepAssetIds: suggestDuplicateKeepAssetIds(mappedAssets), + }; + }); } async delete(auth: AuthDto, id: string): Promise { @@ -29,6 +52,266 @@ export class DuplicateService extends BaseService { await this.duplicateRepository.deleteAll(auth.user.id, dto.ids); } + async resolve(auth: AuthDto, dto: DuplicateResolveDto): Promise { + const results: DuplicateResolveResultDto[] = []; + + for (const group of dto.groups) { + try { + const result = await this.resolveGroup(auth, group, dto.settings); + results.push(result); + } catch (error) { + results.push({ + duplicateId: group.duplicateId, + status: DuplicateResolveStatus.Failed, + reason: `Failed to resolve duplicate group: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + + return { + status: DuplicateResolveBatchStatus.Completed, + results, + }; + } + + private async resolveGroup( + auth: AuthDto, + group: DuplicateResolveGroupDto, + settings: DuplicateResolveSettingsDto, + ): Promise { + const { duplicateId, keepAssetIds, trashAssetIds } = group; + + // Step 1: Validate group ownership and membership + const duplicateGroup = await this.duplicateRepository.getByIdForUser(auth.user.id, duplicateId); + if (!duplicateGroup) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: `Duplicate group ${duplicateId} not found or access denied`, + }; + } + + const groupAssetIds = new Set(duplicateGroup.assets.map((a) => a.id)); + const mappedAssets = duplicateGroup.assets.map((asset) => mapAsset(asset, { auth })); + + // Validate all keepAssetIds are in the group + for (const assetId of keepAssetIds) { + if (!groupAssetIds.has(assetId)) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: `Asset ${assetId} is not a member of duplicate group ${duplicateId}`, + }; + } + } + + // Validate all trashAssetIds are in the group + for (const assetId of trashAssetIds) { + if (!groupAssetIds.has(assetId)) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: `Asset ${assetId} is not a member of duplicate group ${duplicateId}`, + }; + } + } + + // Validate keepAssetIds and trashAssetIds are disjoint + const keepSet = new Set(keepAssetIds); + for (const assetId of trashAssetIds) { + if (keepSet.has(assetId)) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: 'keepAssetIds and trashAssetIds must be disjoint (no overlap)', + }; + } + } + + // Validate keepAssetIds and trashAssetIds cover all assets in the group + const coveredAssetIds = new Set([...keepAssetIds, ...trashAssetIds]); + if (coveredAssetIds.size !== groupAssetIds.size) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: 'keepAssetIds and trashAssetIds must cover all assets in the duplicate group', + }; + } + + // Step 2: Compute idsToKeep (validated above to cover all assets) + const idsToKeep = keepAssetIds; + + // Step 0 (delayed): Pre-check permissions before any database operations + // Check asset update permission for keepers + if (idsToKeep.length > 0) { + const allowedUpdateIds = await this.checkAccess({ + auth, + permission: Permission.AssetUpdate, + ids: idsToKeep, + }); + if (allowedUpdateIds.size !== idsToKeep.length) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: `Not found or no ${Permission.AssetUpdate} access`, + }; + } + } + + // Check asset delete permission for trash assets + if (trashAssetIds.length > 0) { + const allowedDeleteIds = await this.checkAccess({ + auth, + permission: Permission.AssetDelete, + ids: trashAssetIds, + }); + if (allowedDeleteIds.size !== trashAssetIds.length) { + return { + duplicateId, + status: DuplicateResolveStatus.Failed, + reason: `Not found or no ${Permission.AssetDelete} access`, + }; + } + } + + // Step 3: Fetch album IDs if synchronizeAlbums is enabled + const assetAlbumMap = settings.synchronizeAlbums + ? await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]) + : new Map(); + + // Step 4: Run resolve policy + const policy = computeResolvePolicy(mappedAssets, idsToKeep, settings, assetAlbumMap); + + // Step 5: Apply updates in order + if (idsToKeep.length > 0) { + // 5a. Synchronize albums + if (settings.synchronizeAlbums && policy.mergedAlbumIds.length > 0) { + // Pre-check album permissions + const allowedAlbumIds = await this.checkAccess({ + auth, + permission: Permission.AlbumAssetCreate, + ids: policy.mergedAlbumIds, + }); + const allowedShareIds = await this.checkAccess({ + auth, + permission: Permission.AssetShare, + ids: idsToKeep, + }); + + if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) { + await this.albumRepository.addAssetIdsToAlbums( + [...allowedAlbumIds].flatMap((albumId) => + [...allowedShareIds].map((assetId) => ({ albumId, assetId })), + ), + ); + } + } + + // 5b. Synchronize tags + if (settings.synchronizeTags && policy.mergedTagIds.length > 0) { + const allowedTagIds = await this.checkAccess({ + auth, + permission: Permission.TagAsset, + ids: policy.mergedTagIds, + }); + + if (allowedTagIds.size > 0) { + // Replace tags for each keeper asset to ensure all merged tags are applied + await Promise.all( + idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])), + ); + } + } + + // 5c. Update keeper assets + if (policy.assetBulkUpdate.ids.length > 0) { + const { ids, duplicateId: _updateDuplicateId, ...assetFields } = policy.assetBulkUpdate; + const exifFields: Record = {}; + const assetUpdateFields: Record = {}; + + // Separate exif fields from asset fields + for (const [key, value] of Object.entries(assetFields)) { + if (EXIF_FIELDS.includes(key as (typeof EXIF_FIELDS)[number])) { + exifFields[key] = value; + } else { + assetUpdateFields[key] = value; + } + } + + // Update exif fields if any + if (Object.keys(exifFields).length > 0) { + await this.assetRepository.updateAllExif(ids, exifFields); + } + + // Update asset fields including duplicateId: null + await this.assetRepository.updateAll(ids, { ...assetUpdateFields, duplicateId: null }); + } + } + + // 5d/5e. Delete/trash assets + if (trashAssetIds.length > 0) { + const { trash } = await this.getConfig({ withCache: true }); + const force = !trash.enabled; + + await this.assetRepository.updateAll(trashAssetIds, { + deletedAt: new Date(), + status: force ? AssetStatus.Deleted : AssetStatus.Trashed, + duplicateId: null, + }); + + await this.eventRepository.emit(force ? 'AssetDeleteAll' : 'AssetTrashAll', { + assetIds: trashAssetIds, + userId: auth.user.id, + }); + } + + return { + duplicateId, + status: DuplicateResolveStatus.Success, + }; + } + + async stack(auth: AuthDto, dto: DuplicateStackDto): Promise { + const { duplicateId, assetIds, primaryAssetId } = dto; + + // Step 1: Validate duplicate group exists and belongs to user + const duplicateGroup = await this.duplicateRepository.getByIdForUser(auth.user.id, duplicateId); + if (!duplicateGroup) { + throw new BadRequestException(`Duplicate group ${duplicateId} not found or access denied`); + } + + const groupAssetIds = new Set(duplicateGroup.assets.map((a) => a.id)); + + // Step 2: Validate all assetIds are members of the duplicate group + for (const assetId of assetIds) { + if (!groupAssetIds.has(assetId)) { + throw new BadRequestException(`Asset ${assetId} is not a member of duplicate group ${duplicateId}`); + } + } + + // Step 3: Pre-check permissions + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: assetIds }); + + // Step 4: If primaryAssetId provided, validate and reorder + let orderedAssetIds = [...assetIds]; + if (primaryAssetId) { + if (!assetIds.includes(primaryAssetId)) { + throw new BadRequestException(`primaryAssetId must be in assetIds`); + } + // Reorder so primary is first + orderedAssetIds = [primaryAssetId, ...assetIds.filter((id) => id !== primaryAssetId)]; + } + + // Step 5: Create stack + const stack = await this.stackRepository.create({ ownerId: auth.user.id }, orderedAssetIds); + await this.eventRepository.emit('StackCreate', { stackId: stack.id, userId: auth.user.id }); + + // Step 6: Clear duplicate membership + await this.assetRepository.updateAll(assetIds, { duplicateId: null }); + + return mapStack(stack, { auth }); + } + @OnJob({ name: JobName.AssetDetectDuplicatesQueueAll, queue: QueueName.DuplicateDetection }) async handleQueueSearchDuplicates({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); diff --git a/server/src/utils/duplicate-resolve.spec.ts b/server/src/utils/duplicate-resolve.spec.ts new file mode 100644 index 0000000000000..bdf36b27eb133 --- /dev/null +++ b/server/src/utils/duplicate-resolve.spec.ts @@ -0,0 +1,324 @@ +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { DuplicateResolveSettingsDto } from 'src/dtos/duplicate.dto'; +import { AssetType, AssetVisibility } from 'src/enum'; +import { computeResolvePolicy, getSyncedInfo } from 'src/utils/duplicate-resolve'; +import { describe, expect, it } from 'vitest'; + +const createAsset = ( + id: string, + overrides: Partial = {}, +): AssetResponseDto => ({ + id, + type: AssetType.Image, + thumbhash: null, + localDateTime: new Date(), + duration: '0:00:00.00000', + hasMetadata: true, + width: 1920, + height: 1080, + createdAt: new Date(), + deviceAssetId: 'device-asset-1', + deviceId: 'device-1', + ownerId: 'owner-1', + originalPath: '/path/to/asset', + originalFileName: 'asset.jpg', + fileCreatedAt: new Date(), + fileModifiedAt: new Date(), + updatedAt: new Date(), + isFavorite: false, + isArchived: false, + isTrashed: false, + isOffline: false, + visibility: AssetVisibility.Timeline, + checksum: 'checksum', + ...overrides, +}); + +const allDisabledSettings: DuplicateResolveSettingsDto = { + synchronizeAlbums: false, + synchronizeVisibility: false, + synchronizeFavorites: false, + synchronizeRating: false, + synchronizeDescription: false, + synchronizeLocation: false, + synchronizeTags: false, +}; + +const _allEnabledSettings: DuplicateResolveSettingsDto = { + synchronizeAlbums: true, + synchronizeVisibility: true, + synchronizeFavorites: true, + synchronizeRating: true, + synchronizeDescription: true, + synchronizeLocation: true, + synchronizeTags: true, +}; + +describe('duplicate-resolve utils', () => { + describe('getSyncedInfo', () => { + it('should return defaults for empty list', () => { + const result = getSyncedInfo([]); + expect(result).toEqual({ + isFavorite: false, + visibility: undefined, + rating: 0, + description: null, + latitude: null, + longitude: null, + tagIds: [], + }); + }); + + describe('isFavorite', () => { + it('should return false if no assets are favorite', () => { + const assets = [createAsset('1', { isFavorite: false }), createAsset('2', { isFavorite: false })]; + expect(getSyncedInfo(assets).isFavorite).toBe(false); + }); + + it('should return true if any asset is favorite', () => { + const assets = [createAsset('1', { isFavorite: false }), createAsset('2', { isFavorite: true })]; + expect(getSyncedInfo(assets).isFavorite).toBe(true); + }); + }); + + describe('visibility', () => { + it('should return undefined if no special visibility', () => { + const assets = [createAsset('1', { visibility: AssetVisibility.Timeline })]; + expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Timeline); + }); + + it('should prioritize Locked over Archive and Timeline', () => { + const assets = [ + createAsset('1', { visibility: AssetVisibility.Timeline }), + createAsset('2', { visibility: AssetVisibility.Archive }), + createAsset('3', { visibility: AssetVisibility.Locked }), + ]; + expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Locked); + }); + + it('should prioritize Archive over Timeline', () => { + const assets = [ + createAsset('1', { visibility: AssetVisibility.Timeline }), + createAsset('2', { visibility: AssetVisibility.Archive }), + ]; + expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Archive); + }); + + it('should use Hidden if no standard visibility but Hidden is present', () => { + const assets = [createAsset('1', { visibility: AssetVisibility.Hidden })]; + expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Hidden); + }); + }); + + describe('rating', () => { + it('should return 0 if no ratings', () => { + const assets = [createAsset('1'), createAsset('2')]; + expect(getSyncedInfo(assets).rating).toBe(0); + }); + + it('should return max rating', () => { + const assets = [ + createAsset('1', { exifInfo: { rating: 3 } }), + createAsset('2', { exifInfo: { rating: 5 } }), + createAsset('3', { exifInfo: { rating: 1 } }), + ]; + expect(getSyncedInfo(assets).rating).toBe(5); + }); + }); + + describe('description', () => { + it('should return null if no descriptions', () => { + const assets = [createAsset('1'), createAsset('2')]; + expect(getSyncedInfo(assets).description).toBeNull(); + }); + + it('should concatenate unique non-empty lines', () => { + const assets = [ + createAsset('1', { exifInfo: { description: 'Line 1\nLine 2' } }), + createAsset('2', { exifInfo: { description: 'Line 2\nLine 3' } }), + ]; + expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should trim lines and skip empty', () => { + const assets = [createAsset('1', { exifInfo: { description: ' Line 1 \n\n Line 2 \n ' } })]; + expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2'); + }); + }); + + describe('location', () => { + it('should return null if no location data', () => { + const assets = [createAsset('1'), createAsset('2')]; + const result = getSyncedInfo(assets); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeNull(); + }); + + it('should return coordinates if all assets have same location', () => { + const assets = [ + createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + createAsset('2', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + ]; + const result = getSyncedInfo(assets); + expect(result.latitude).toBe(40.7128); + expect(result.longitude).toBe(-74.006); + }); + + it('should return null if locations differ', () => { + const assets = [ + createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + createAsset('2', { exifInfo: { latitude: 34.0522, longitude: -118.2437 } }), + ]; + const result = getSyncedInfo(assets); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeNull(); + }); + + it('should ignore assets with missing location', () => { + const assets = [ + createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + createAsset('2', { exifInfo: {} }), + ]; + const result = getSyncedInfo(assets); + expect(result.latitude).toBe(40.7128); + expect(result.longitude).toBe(-74.006); + }); + }); + + describe('tagIds', () => { + it('should return empty array if no tags', () => { + const assets = [createAsset('1'), createAsset('2')]; + expect(getSyncedInfo(assets).tagIds).toEqual([]); + }); + + it('should collect unique tag IDs from all assets', () => { + const assets = [ + createAsset('1', { tags: [{ id: 'tag-1', name: 'Tag 1', value: 'tag-1', createdAt: new Date(), updatedAt: new Date() }] }), + createAsset('2', { tags: [{ id: 'tag-1', name: 'Tag 1', value: 'tag-1', createdAt: new Date(), updatedAt: new Date() }, { id: 'tag-2', name: 'Tag 2', value: 'tag-2', createdAt: new Date(), updatedAt: new Date() }] }), + ]; + const result = getSyncedInfo(assets); + expect(result.tagIds).toHaveLength(2); + expect(result.tagIds).toContain('tag-1'); + expect(result.tagIds).toContain('tag-2'); + }); + }); + }); + + describe('computeResolvePolicy', () => { + it('should always set duplicateId to null in assetBulkUpdate', () => { + const assets = [createAsset('1'), createAsset('2')]; + const policy = computeResolvePolicy(assets, ['1'], allDisabledSettings); + expect(policy.assetBulkUpdate.duplicateId).toBeNull(); + }); + + it('should set ids to idsToKeep', () => { + const assets = [createAsset('1'), createAsset('2')]; + const policy = computeResolvePolicy(assets, ['1', '2'], allDisabledSettings); + expect(policy.assetBulkUpdate.ids).toEqual(['1', '2']); + }); + + it('should not set sync fields when all settings disabled', () => { + const assets = [ + createAsset('1', { + isFavorite: true, + visibility: AssetVisibility.Archive, + exifInfo: { rating: 5, description: 'test' }, + }), + ]; + const policy = computeResolvePolicy(assets, ['1'], allDisabledSettings); + + expect(policy.assetBulkUpdate.isFavorite).toBeUndefined(); + expect(policy.assetBulkUpdate.visibility).toBeUndefined(); + expect(policy.assetBulkUpdate.rating).toBeUndefined(); + expect(policy.assetBulkUpdate.description).toBeUndefined(); + expect(policy.mergedAlbumIds).toEqual([]); + expect(policy.mergedTagIds).toEqual([]); + }); + + it('should set isFavorite when synchronizeFavorites enabled', () => { + const assets = [createAsset('1', { isFavorite: true }), createAsset('2', { isFavorite: false })]; + const settings = { ...allDisabledSettings, synchronizeFavorites: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.isFavorite).toBe(true); + }); + + it('should set visibility when synchronizeVisibility enabled', () => { + const assets = [ + createAsset('1', { visibility: AssetVisibility.Archive }), + createAsset('2', { visibility: AssetVisibility.Timeline }), + ]; + const settings = { ...allDisabledSettings, synchronizeVisibility: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.visibility).toBe(AssetVisibility.Archive); + }); + + it('should set rating when synchronizeRating enabled', () => { + const assets = [createAsset('1', { exifInfo: { rating: 3 } }), createAsset('2', { exifInfo: { rating: 5 } })]; + const settings = { ...allDisabledSettings, synchronizeRating: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.rating).toBe(5); + }); + + it('should set description when synchronizeDescription enabled and non-null', () => { + const assets = [createAsset('1', { exifInfo: { description: 'Test description' } })]; + const settings = { ...allDisabledSettings, synchronizeDescription: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.description).toBe('Test description'); + }); + + it('should not set description when null', () => { + const assets = [createAsset('1')]; + const settings = { ...allDisabledSettings, synchronizeDescription: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.description).toBeUndefined(); + }); + + it('should set location when synchronizeLocation enabled and coordinates match', () => { + const assets = [ + createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + createAsset('2', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + ]; + const settings = { ...allDisabledSettings, synchronizeLocation: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.latitude).toBe(40.7128); + expect(policy.assetBulkUpdate.longitude).toBe(-74.006); + }); + + it('should not set location when coordinates differ', () => { + const assets = [ + createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), + createAsset('2', { exifInfo: { latitude: 34.0522, longitude: -118.2437 } }), + ]; + const settings = { ...allDisabledSettings, synchronizeLocation: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.assetBulkUpdate.latitude).toBeUndefined(); + expect(policy.assetBulkUpdate.longitude).toBeUndefined(); + }); + + it('should return merged album IDs when synchronizeAlbums enabled', () => { + const assets = [createAsset('1'), createAsset('2')]; + const settings = { ...allDisabledSettings, synchronizeAlbums: true }; + const assetAlbumMap = new Map([ + ['1', ['album-1', 'album-2']], + ['2', ['album-2', 'album-3']], + ]); + const policy = computeResolvePolicy(assets, ['1'], settings, assetAlbumMap); + expect(policy.mergedAlbumIds).toHaveLength(3); + expect(policy.mergedAlbumIds).toContain('album-1'); + expect(policy.mergedAlbumIds).toContain('album-2'); + expect(policy.mergedAlbumIds).toContain('album-3'); + }); + + it('should return merged tag IDs when synchronizeTags enabled', () => { + const assets = [ + createAsset('1', { tags: [{ id: 'tag-1', name: 'Tag 1', value: 'tag-1', createdAt: new Date(), updatedAt: new Date() }] }), + createAsset('2', { tags: [{ id: 'tag-2', name: 'Tag 2', value: 'tag-2', createdAt: new Date(), updatedAt: new Date() }] }), + ]; + const settings = { ...allDisabledSettings, synchronizeTags: true }; + const policy = computeResolvePolicy(assets, ['1'], settings); + expect(policy.mergedTagIds).toHaveLength(2); + expect(policy.mergedTagIds).toContain('tag-1'); + expect(policy.mergedTagIds).toContain('tag-2'); + }); + }); +}); diff --git a/server/src/utils/duplicate-resolve.ts b/server/src/utils/duplicate-resolve.ts new file mode 100644 index 0000000000000..b3aa0c8f6ef10 --- /dev/null +++ b/server/src/utils/duplicate-resolve.ts @@ -0,0 +1,197 @@ +import { AssetBulkUpdateDto } from 'src/dtos/asset.dto'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { DuplicateResolveSettingsDto } from 'src/dtos/duplicate.dto'; +import { AssetVisibility } from 'src/enum'; + +/** + * Represents the synchronized information computed from a duplicate group. + * This mirrors the client-side getSyncedInfo function behavior. + */ +export interface SyncedInfo { + isFavorite: boolean; + visibility: AssetVisibility | undefined; + rating: number; + description: string | null; + latitude: number | null; + longitude: number | null; + tagIds: string[]; +} + +/** + * Result of computing the resolve policy for a duplicate group. + */ +export interface ResolvePolicy { + /** Bulk update to apply to keeper assets */ + assetBulkUpdate: AssetBulkUpdateDto; + /** Album IDs to add keeper assets to (if synchronizeAlbums enabled) */ + mergedAlbumIds: string[]; + /** Tag IDs to apply to keeper assets (if synchronizeTags enabled) */ + mergedTagIds: string[]; +} + +/** + * Computes synchronized information from a list of assets. + * This mirrors the client-side getSyncedInfo function in the duplicates page. + * + * @param assets List of assets in the duplicate group (full AssetResponseDto with exif, tags, etc.) + * @returns Computed synced info + */ +export const getSyncedInfo = (assets: AssetResponseDto[]): SyncedInfo => { + if (assets.length === 0) { + return { + isFavorite: false, + visibility: undefined, + rating: 0, + description: null, + latitude: null, + longitude: null, + tagIds: [], + }; + } + + // If any of the assets is favorite, we consider the synced info as favorite + const isFavorite = assets.some((asset) => asset.isFavorite); + + // Choose the most restrictive user-visible level (Hidden is internal-only) + const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline]; + let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); + if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) { + visibility = AssetVisibility.Hidden; + } + + // Choose the highest rating from the exif data of the assets + let rating = 0; + for (const asset of assets) { + 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): string[] => { + const unique = new Set(); + 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 descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); + const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; + + // Helper: return unique numeric coordinate or null + const getUniqueCoordinate = (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 Set(values); + return unique.size === 1 ? [...unique][0] : null; + }; + + const latitude = getUniqueCoordinate('latitude'); + const longitude = getUniqueCoordinate('longitude'); + + // Collect all unique tag IDs from all assets + const tagIds = [ + ...new Set( + assets + .flatMap((asset) => asset.tags ?? []) + .map((tag) => tag.id) + .filter((id): id is string => !!id), + ), + ]; + + return { isFavorite, visibility, rating, description, latitude, longitude, tagIds }; +}; + +/** + * Computes the resolve policy for a duplicate group based on settings and assets. + * This is pure domain logic with no database access. + * + * @param assets All assets in the duplicate group + * @param idsToKeep Asset IDs that will be kept (derived from request) + * @param settings Sync settings from the request + * @param assetAlbumMap Map of asset IDs to their album IDs (only needed if synchronizeAlbums is enabled) + * @returns The resolve policy to apply + */ +export const computeResolvePolicy = ( + assets: AssetResponseDto[], + idsToKeep: string[], + settings: DuplicateResolveSettingsDto, + assetAlbumMap: Map = new Map(), +): ResolvePolicy => { + const needsSyncedInfo = + settings.synchronizeFavorites || + settings.synchronizeVisibility || + settings.synchronizeRating || + settings.synchronizeDescription || + settings.synchronizeLocation || + settings.synchronizeTags; + + const syncedInfo = needsSyncedInfo ? getSyncedInfo(assets) : null; + + // Build the asset bulk update for keeper assets + const assetBulkUpdate: AssetBulkUpdateDto = { + ids: idsToKeep, + duplicateId: null, // Always clear the duplicate ID + }; + + if (settings.synchronizeFavorites && syncedInfo) { + assetBulkUpdate.isFavorite = syncedInfo.isFavorite; + } + + if (settings.synchronizeVisibility && syncedInfo) { + assetBulkUpdate.visibility = syncedInfo.visibility; + } + + if (settings.synchronizeRating && syncedInfo) { + assetBulkUpdate.rating = syncedInfo.rating; + } + + if (settings.synchronizeDescription && syncedInfo && syncedInfo.description !== null) { + assetBulkUpdate.description = syncedInfo.description; + } + + // If all assets have the same location, use it; otherwise don't set it (leave as-is) + if (settings.synchronizeLocation && syncedInfo && syncedInfo.latitude !== null && syncedInfo.longitude !== null) { + assetBulkUpdate.latitude = syncedInfo.latitude; + assetBulkUpdate.longitude = syncedInfo.longitude; + } + + // Compute merged album IDs if synchronizeAlbums is enabled + let mergedAlbumIds: string[] = []; + if (settings.synchronizeAlbums) { + const albumIdSet = new Set(); + for (const [, albumIds] of assetAlbumMap) { + for (const albumId of albumIds) { + albumIdSet.add(albumId); + } + } + mergedAlbumIds = [...albumIdSet]; + } + + // Merged tag IDs from synced info if synchronizeTags is enabled + const mergedTagIds = settings.synchronizeTags && syncedInfo ? syncedInfo.tagIds : []; + + return { + assetBulkUpdate, + mergedAlbumIds, + mergedTagIds, + }; +}; diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts new file mode 100644 index 0000000000000..62ce2399fd3a1 --- /dev/null +++ b/server/src/utils/duplicate.spec.ts @@ -0,0 +1,176 @@ +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AssetType, AssetVisibility } from 'src/enum'; +import { getExifCount, suggestDuplicate, suggestDuplicateKeepAssetIds } from 'src/utils/duplicate'; +import { describe, expect, it } from 'vitest'; + +const createAsset = ( + id: string, + fileSizeInByte: number | null = null, + exifFields: Record = {}, +): AssetResponseDto => ({ + id, + type: AssetType.Image, + thumbhash: null, + localDateTime: new Date(), + duration: '0:00:00.00000', + hasMetadata: true, + width: 1920, + height: 1080, + createdAt: new Date(), + deviceAssetId: 'device-asset-1', + deviceId: 'device-1', + ownerId: 'owner-1', + originalPath: '/path/to/asset', + originalFileName: 'asset.jpg', + fileCreatedAt: new Date(), + fileModifiedAt: new Date(), + updatedAt: new Date(), + isFavorite: false, + isArchived: false, + isTrashed: false, + isOffline: false, + visibility: AssetVisibility.Timeline, + checksum: 'checksum', + exifInfo: fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? { fileSizeInByte, ...exifFields } : undefined, +}); + +describe('duplicate utils', () => { + describe('getExifCount', () => { + it('should return 0 for asset without exifInfo', () => { + const asset = createAsset('asset-1'); + asset.exifInfo = undefined; + expect(getExifCount(asset)).toBe(0); + }); + + it('should return 0 for empty exifInfo', () => { + const asset = createAsset('asset-1'); + asset.exifInfo = {}; + expect(getExifCount(asset)).toBe(0); + }); + + it('should count all truthy values in exifInfo', () => { + const asset = createAsset('asset-1', 1000, { + make: 'Canon', + model: 'EOS 5D', + dateTimeOriginal: new Date(), + timeZone: 'UTC', + latitude: 40.7128, + longitude: -74.006, + city: 'New York', + state: 'NY', + country: 'USA', + description: 'A photo', + rating: 5, + }); + // fileSizeInByte (1000) + 11 other truthy fields = 12 + expect(getExifCount(asset)).toBe(12); + }); + + it('should not count null or undefined values', () => { + const asset = createAsset('asset-1', 1000, { + make: 'Canon', + model: null, + latitude: undefined, + city: '', + rating: 0, + }); + // fileSizeInByte (1000) + make ('Canon') = 2 truthy values + // model (null), latitude (undefined), city (''), rating (0) are all falsy + expect(getExifCount(asset)).toBe(2); + }); + }); + + describe('suggestDuplicate', () => { + it('should return undefined for empty list', () => { + expect(suggestDuplicate([])).toBeUndefined(); + }); + + it('should return the single asset for list with one asset', () => { + const asset = createAsset('asset-1', 1000); + expect(suggestDuplicate([asset])).toEqual(asset); + }); + + it('should return asset with largest file size', () => { + const small = createAsset('small', 1000); + const large = createAsset('large', 5000); + const medium = createAsset('medium', 3000); + + expect(suggestDuplicate([small, large, medium])?.id).toBe('large'); + expect(suggestDuplicate([large, small, medium])?.id).toBe('large'); + expect(suggestDuplicate([medium, small, large])?.id).toBe('large'); + }); + + it('should use EXIF count as tie-breaker when file sizes are equal', () => { + const lessExif = createAsset('less-exif', 1000, { make: 'Canon' }); + const moreExif = createAsset('more-exif', 1000, { + make: 'Canon', + model: 'EOS 5D', + dateTimeOriginal: new Date(), + city: 'New York', + }); + + expect(suggestDuplicate([lessExif, moreExif])?.id).toBe('more-exif'); + expect(suggestDuplicate([moreExif, lessExif])?.id).toBe('more-exif'); + }); + + it('should handle assets with no exifInfo (treat as 0 file size)', () => { + const noExif = createAsset('no-exif'); + noExif.exifInfo = undefined; + const withExif = createAsset('with-exif', 1000); + + expect(suggestDuplicate([noExif, withExif])?.id).toBe('with-exif'); + }); + + it('should handle assets with exifInfo but no fileSizeInByte', () => { + const noFileSize = createAsset('no-file-size'); + noFileSize.exifInfo = { make: 'Canon', model: 'EOS 5D' }; + const withFileSize = createAsset('with-file-size', 1000); + + expect(suggestDuplicate([noFileSize, withFileSize])?.id).toBe('with-file-size'); + }); + + it('should return last asset when all have same file size and EXIF count', () => { + const asset1 = createAsset('asset-1', 1000, { make: 'Canon' }); + const asset2 = createAsset('asset-2', 1000, { make: 'Nikon' }); + + // Both have same file size (1000) and same EXIF count (2: fileSizeInByte + make) + // Should return the last one in the sorted array + const result = suggestDuplicate([asset1, asset2]); + // Since they're equal, the last one after sorting should be returned + expect(result).toBeDefined(); + expect(['asset-1', 'asset-2']).toContain(result?.id); + }); + + it('should prioritize file size over EXIF count', () => { + const largeWithLessExif = createAsset('large-less-exif', 5000, { make: 'Canon' }); + const smallWithMoreExif = createAsset('small-more-exif', 1000, { + make: 'Canon', + model: 'EOS 5D', + dateTimeOriginal: new Date(), + city: 'New York', + state: 'NY', + country: 'USA', + }); + + expect(suggestDuplicate([largeWithLessExif, smallWithMoreExif])?.id).toBe('large-less-exif'); + }); + }); + + describe('suggestDuplicateKeepAssetIds', () => { + it('should return empty array for empty list', () => { + expect(suggestDuplicateKeepAssetIds([])).toEqual([]); + }); + + it('should return array with single asset ID', () => { + const asset = createAsset('asset-1', 1000); + expect(suggestDuplicateKeepAssetIds([asset])).toEqual(['asset-1']); + }); + + it('should return array with best asset ID', () => { + const small = createAsset('small', 1000); + const large = createAsset('large', 5000); + + expect(suggestDuplicateKeepAssetIds([small, large])).toEqual(['large']); + }); + }); +}); diff --git a/server/src/utils/duplicate.ts b/server/src/utils/duplicate.ts new file mode 100644 index 0000000000000..4f6deb2fce843 --- /dev/null +++ b/server/src/utils/duplicate.ts @@ -0,0 +1,60 @@ +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; + +/** + * Counts all truthy values in the exifInfo object. + * This matches the client implementation in web/src/lib/utils/exif-utils.ts + * + * @param asset Asset with optional exifInfo + * @returns Count of truthy EXIF values + */ +export const getExifCount = (asset: AssetResponseDto): number => { + return Object.values(asset.exifInfo ?? {}).filter(Boolean).length; +}; + +/** + * Suggests the best duplicate asset to keep from a list of duplicates. + * This is a direct port of the client logic from web/src/lib/utils/duplicate-utils.ts + * + * The best asset is determined by the following criteria: + * 1. Largest image file size in bytes + * 2. Largest count of EXIF data (as tie-breaker) + * + * @param assets List of duplicate assets + * @returns The best asset to keep, or undefined if empty list + */ +export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => { + if (assets.length === 0) { + return undefined; + } + + // Sort by file size ascending (smallest first) + let duplicateAssets = [...assets].toSorted( + (a, b) => (a.exifInfo?.fileSizeInByte ?? 0) - (b.exifInfo?.fileSizeInByte ?? 0), + ); + + // Get the largest file size (last element after sorting) + const largestFileSize = duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte ?? 0; + + // Filter to keep only assets with the largest file size + duplicateAssets = duplicateAssets.filter((asset) => (asset.exifInfo?.fileSizeInByte ?? 0) === largestFileSize); + + // If there are multiple assets with the same file size, sort by EXIF count + if (duplicateAssets.length >= 2) { + duplicateAssets = duplicateAssets.toSorted((a, b) => getExifCount(a) - getExifCount(b)); + } + + // Return the last asset (highest EXIF count among highest file size) + return duplicateAssets.at(-1); +}; + +/** + * Suggests the best duplicate asset IDs to keep from a list of duplicates. + * Returns an array with a single asset ID (the best candidate), or empty if no assets. + * + * @param assets List of duplicate assets + * @returns Array of suggested asset IDs to keep (0 or 1 element) + */ +export const suggestDuplicateKeepAssetIds = (assets: AssetResponseDto[]): string[] => { + const suggested = suggestDuplicate(assets); + return suggested ? [suggested.id] : []; +}; diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index aba2dc01f42af..2d3db788081b5 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -6,7 +6,6 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { handlePromiseError } from '$lib/utils'; import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; - import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { navigate } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; @@ -17,11 +16,12 @@ interface Props { assets: AssetResponseDto[]; + suggestedKeepAssetIds: string[]; onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; onStack: (assets: AssetResponseDto[]) => void; } - let { assets, onResolve, onStack }: Props = $props(); + let { assets, suggestedKeepAssetIds, onResolve, onStack }: Props = $props(); const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore; // eslint-disable-next-line svelte/no-unnecessary-state-wrap @@ -29,14 +29,18 @@ let trashCount = $derived(assets.length - selectedAssetIds.size); onMount(() => { - const suggestedAsset = suggestDuplicate(assets); - - if (!suggestedAsset) { - selectedAssetIds = new SvelteSet(assets[0].id); + // Use server-provided suggested keep asset IDs + if (suggestedKeepAssetIds.length > 0) { + for (const id of suggestedKeepAssetIds) { + selectedAssetIds.add(id); + } return; } - selectedAssetIds.add(suggestedAsset.id); + // Fallback to first asset if no suggestion + if (assets.length > 0) { + selectedAssetIds.add(assets[0].id); + } }); onDestroy(() => { diff --git a/web/src/lib/utils/duplicate-utils.spec.ts b/web/src/lib/utils/duplicate-utils.spec.ts deleted file mode 100644 index 4fa427989a26a..0000000000000 --- a/web/src/lib/utils/duplicate-utils.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { suggestDuplicate } from '$lib/utils/duplicate-utils'; -import type { AssetResponseDto } from '@immich/sdk'; - -describe('choosing a duplicate', () => { - it('picks the asset with the largest file size', () => { - const assets = [ - { exifInfo: { fileSizeInByte: 300 } }, - { exifInfo: { fileSizeInByte: 200 } }, - { exifInfo: { fileSizeInByte: 100 } }, - ]; - expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]); - }); - - it('picks the asset with the most exif data if multiple assets have the same file size', () => { - const assets = [ - { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1 } }, - { exifInfo: { fileSizeInByte: 200, rating: 5 } }, - { exifInfo: { fileSizeInByte: 100, rating: 5 } }, - ]; - expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]); - }); - - it('returns undefined for an empty array', () => { - const assets: AssetResponseDto[] = []; - expect(suggestDuplicate(assets)).toBeUndefined(); - }); - - it('handles assets with no exifInfo', () => { - const assets = [{ exifInfo: { fileSizeInByte: 200 } }, {}]; - expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]); - }); - - it('handles assets with exifInfo but no fileSizeInByte', () => { - const assets = [{ exifInfo: { rating: 5, fNumber: 1 } }, { exifInfo: { rating: 5 } }]; - expect(suggestDuplicate(assets as AssetResponseDto[])).toEqual(assets[0]); - }); -}); diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts deleted file mode 100644 index 1c783a36677d8..0000000000000 --- a/web/src/lib/utils/duplicate-utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getExifCount } from '$lib/utils/exif-utils'; -import type { AssetResponseDto } from '@immich/sdk'; -import { sortBy } from 'lodash-es'; - -/** - * Suggests the best duplicate asset to keep from a list of duplicates. - * - * The best asset is determined by the following criteria: - * - Largest image file size in bytes - * - Largest count of exif data - * - * @param assets List of duplicate assets - * @returns The best asset to keep - */ -export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => { - let duplicateAssets = sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte ?? 0); - - // Update the list to only include assets with the largest file size - duplicateAssets = duplicateAssets.filter( - (asset) => asset.exifInfo?.fileSizeInByte === duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte, - ); - - // If there are multiple assets with the same file size, sort the list by the count of exif data - if (duplicateAssets.length >= 2) { - duplicateAssets = sortBy(duplicateAssets, getExifCount); - } - - // Return the last asset in the list - return duplicateAssets.pop(); -}; 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 1ee7e51149740..fc1d157cec17f 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 @@ -5,27 +5,15 @@ 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 { duplicateSettings, locale } from '$lib/stores/preferences.store'; - import { stackAssets } from '$lib/utils/asset-utils'; - import { suggestDuplicate } from '$lib/utils/duplicate-utils'; import { handleError } from '$lib/utils/handle-error'; - import type { AssetBulkUpdateDto, AssetResponseDto } from '@immich/sdk'; - import { - addAssetsToAlbums, - AssetVisibility, - bulkTagAssets, - deleteAssets, - deleteDuplicates, - getAllAlbums, - getAssetInfo, - updateAssets, - } from '@immich/sdk'; + import type { AssetResponseDto } from '@immich/sdk'; + import { deleteDuplicates, resolveDuplicates, stackDuplicates, Status } from '@immich/sdk'; import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui'; import { mdiCheckOutline, @@ -39,7 +27,6 @@ mdiTrashCanOutline, } from '@mdi/js'; import { t } from 'svelte-i18n'; - import { SvelteSet } from 'svelte/reactivity'; import type { PageData } from './$types'; interface Props { @@ -118,160 +105,34 @@ toastManager.success(message); }; - 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 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 - 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 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: idsToKeep, - duplicateId: null, - }; - if ($duplicateSettings.synchronizeFavorites && syncedInfo) { - assetBulkUpdate.isFavorite = syncedInfo.isFavorite; - } - if ($duplicateSettings.synchronizeVisibility && syncedInfo) { - assetBulkUpdate.visibility = syncedInfo.visibility; - } - if ($duplicateSettings.synchronizeRating && syncedInfo) { - assetBulkUpdate.rating = syncedInfo.rating; - } - if ($duplicateSettings.synchronizeDescription && syncedInfo && syncedInfo.description !== null) { - assetBulkUpdate.description = syncedInfo.description; - } - // 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 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 } }); - } - } + const keepAssetIds = duplicateAssetIds.filter((id) => !trashIds.includes(id)); + + const response = await resolveDuplicates({ + duplicateResolveDto: { + groups: [{ duplicateId, keepAssetIds, trashAssetIds: trashIds }], + settings: { + synchronizeAlbums: $duplicateSettings.synchronizeAlbums, + synchronizeVisibility: $duplicateSettings.synchronizeVisibility, + synchronizeFavorites: $duplicateSettings.synchronizeFavorites, + synchronizeRating: $duplicateSettings.synchronizeRating, + synchronizeDescription: $duplicateSettings.synchronizeDescription, + synchronizeLocation: $duplicateSettings.synchronizeLocation, + synchronizeTags: $duplicateSettings.synchronizeTags, + }, + }, + }); - if ($duplicateSettings.synchronizeTags && idsToKeep.length > 0 && syncedInfo && syncedInfo.tagIds.length > 0) { - await bulkTagAssets({ tagBulkAssetsDto: { tagIds: syncedInfo.tagIds, assetIds: idsToKeep } }); + const result = response.results[0]; + if (result.status === Status.Failed) { + throw new Error(result.reason ?? 'Failed to resolve duplicate group'); } - 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); @@ -283,18 +144,23 @@ }; const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => { - await stackAssets(assets, false); - const duplicateAssetIds = assets.map((asset) => asset.id); - await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); + const assetIds = assets.map((asset) => asset.id); + await stackDuplicates({ + duplicateStackDto: { + duplicateId, + assetIds, + }, + }); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); await navigateToIndex(duplicatesIndex); }; const handleDeduplicateAll = async () => { - const idsToKeep = duplicates.map((group) => suggestDuplicate(group.assets)).map((asset) => asset?.id); - const idsToDelete = duplicates.flatMap((group, i) => - group.assets.map((asset) => asset.id).filter((asset) => asset !== idsToKeep[i]), - ); + // Use server-provided suggestedKeepAssetIds from each group + const idsToDelete = duplicates.flatMap((group) => { + const keepIds = new Set(group.suggestedKeepAssetIds); + return group.assets.map((asset) => asset.id).filter((id) => !keepIds.has(id)); + }); let prompt, confirmText; if (featureFlagsManager.value.trash) { @@ -307,14 +173,35 @@ return withConfirmation( async () => { - await deleteAssets({ assetBulkDeleteDto: { ids: idsToDelete, force: !featureFlagsManager.value.trash } }); - await updateAssets({ - assetBulkUpdateDto: { - ids: [...idsToDelete, ...idsToKeep.filter((id): id is string => !!id)], - duplicateId: null, + // Resolve all groups in a single batch request + const response = await resolveDuplicates({ + duplicateResolveDto: { + groups: duplicates.map((group) => { + const keepIds = new Set(group.suggestedKeepAssetIds); + return { + duplicateId: group.duplicateId, + keepAssetIds: group.suggestedKeepAssetIds, + trashAssetIds: group.assets.map((asset) => asset.id).filter((id) => !keepIds.has(id)), + }; + }), + settings: { + synchronizeAlbums: $duplicateSettings.synchronizeAlbums, + synchronizeVisibility: $duplicateSettings.synchronizeVisibility, + synchronizeFavorites: $duplicateSettings.synchronizeFavorites, + synchronizeRating: $duplicateSettings.synchronizeRating, + synchronizeDescription: $duplicateSettings.synchronizeDescription, + synchronizeLocation: $duplicateSettings.synchronizeLocation, + synchronizeTags: $duplicateSettings.synchronizeTags, + }, }, }); + // Count failures and show appropriate message + const failedCount = response.results.filter((r) => r.status === Status.Failed).length; + if (failedCount > 0) { + toastManager.danger($t('errors.unable_to_resolve_duplicate')); + } + duplicates = []; deletedNotification(idsToDelete.length); @@ -436,6 +323,7 @@ {#key duplicates[duplicatesIndex].duplicateId} handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)} onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)} From 97d530d5802eeeaf998802ccdeef9f71413e7bd0 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 16 Jan 2026 21:50:56 +0100 Subject: [PATCH 07/25] feat(preferences): enable all duplicate sync settings by default --- web/src/lib/stores/preferences.store.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index f10106034b59f..52a9454ce72d9 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -163,13 +163,13 @@ export interface DuplicateSettings { } const defaultDuplicateSettings: DuplicateSettings = { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, + synchronizeAlbums: true, + synchronizeVisibility: true, + synchronizeFavorites: true, + synchronizeRating: true, + synchronizeDescription: true, + synchronizeLocation: true, + synchronizeTags: true, }; const normalizeDuplicateSettings = ( From 1f490e1fa91b8f14b957bab42a50e9f05c01681b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 2 Feb 2026 10:30:48 -0500 Subject: [PATCH 08/25] chore: clean up --- docs/docs/features/duplicates-utility.md | 20 ++++++ i18n/en.json | 18 ++---- web/src/lib/components/LinkToDocs.svelte | 16 +++++ .../duplicate-settings-modal.svelte | 62 ------------------- .../lib/modals/DuplicateSettingsModal.svelte | 52 ++++++++++++++++ web/src/lib/route.ts | 6 ++ web/src/lib/stores/preferences.store.ts | 54 +++------------- web/src/lib/types.ts | 10 +++ .../[[assetId=id]]/+page.svelte | 31 ++-------- 9 files changed, 122 insertions(+), 147 deletions(-) create mode 100644 docs/docs/features/duplicates-utility.md create mode 100644 web/src/lib/components/LinkToDocs.svelte delete mode 100644 web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte create mode 100644 web/src/lib/modals/DuplicateSettingsModal.svelte diff --git a/docs/docs/features/duplicates-utility.md b/docs/docs/features/duplicates-utility.md new file mode 100644 index 0000000000000..07b8ceada1523 --- /dev/null +++ b/docs/docs/features/duplicates-utility.md @@ -0,0 +1,20 @@ +# Duplicates Utility + +Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates). + +## Reviewing duplicates + +The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack. + +### Synchronizing metadata + +Additionally, there are synchronization settings that can be used to synchronize metadata between assets in the group. See the table below for more information about what metadata is available to synchronize. + +| Name | Default | Description | +| ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Album | `false` | When enabled, the kept assets will be added to _every_ album that the other assets in the group belong to, including assets that are selected to be trashed. | +| Favorite | `false` | When enabled, if any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | +| Rating | `false` | When enabled, if one or more assets in the duplicate group have a rating, the highest rating is selected and then synchronized to the kept assets. | +| Description | `false` | When enabled, descriptions from each asset are combined together and then synchronized to all the kept assets. | +| Visibility | `false` | When enabled, the most restrictive visibility is applied the the kept assets. | +| Location | `false` | When enabled, latitude and longitude are only copied if among the group there is a single asset with geolocation data. | diff --git a/i18n/en.json b/i18n/en.json index 358151a3fd055..a6072c22e10c0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -966,6 +966,8 @@ "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", "duplicates_description": "Resolve each group by indicating which, if any, are duplicates", + "duplicates_synchronize_setting_description": "Configure which asset metadata is copied to assets that are kept.", + "duplicates_synchronize_settings": "Synchronize", "duration": "Duration", "edit": "Edit", "edit_album": "Edit album", @@ -1374,6 +1376,7 @@ "like": "Like", "like_deleted": "Like deleted", "link_motion_video": "Link motion video", + "link_to_docs": "For more information, refer to the documentation.", "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", "list": "List", @@ -2185,20 +2188,6 @@ "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_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 → Archive → Timeline).", "tag": "Tag", "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", @@ -2380,6 +2369,7 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack", + "visibility": "Visibility", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "visual": "Visual", "visual_builder": "Visual builder", diff --git a/web/src/lib/components/LinkToDocs.svelte b/web/src/lib/components/LinkToDocs.svelte new file mode 100644 index 0000000000000..604b1ac14b59a --- /dev/null +++ b/web/src/lib/components/LinkToDocs.svelte @@ -0,0 +1,16 @@ + + + + {#snippet children({ message })} + {message} + {/snippet} + 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 deleted file mode 100644 index 6335eee5a2ecf..0000000000000 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-settings-modal.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - - - -
- - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - - -
diff --git a/web/src/lib/modals/DuplicateSettingsModal.svelte b/web/src/lib/modals/DuplicateSettingsModal.svelte new file mode 100644 index 0000000000000..be8b56cf6222c --- /dev/null +++ b/web/src/lib/modals/DuplicateSettingsModal.svelte @@ -0,0 +1,52 @@ + + + + {$t('duplicates_synchronize_settings')} + + {$t('duplicates_synchronize_setting_description')} + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts index 5a59ec704c1b5..4cb19651222c5 100644 --- a/web/src/lib/route.ts +++ b/web/src/lib/route.ts @@ -42,6 +42,12 @@ const asQueryString = ( return items.length === 0 ? '' : `?${items.join('&')}`; }; +const DOCS_BASE = 'https://docs.immich.app'; + +export const Docs = { + duplicates: () => `${DOCS_BASE}/features/duplicates-utility`, +}; + export const Route = { // auth login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params), diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 52a9454ce72d9..baa776e22f1cf 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -1,6 +1,8 @@ import { browser } from '$app/environment'; import { Theme, defaultLang } from '$lib/constants'; +import type { DuplicateSettings } from '$lib/types'; import { getPreferredLocale } from '$lib/utils/i18n'; +import { PersistedLocalStorage } from '$lib/utils/persisted'; import { persisted } from 'svelte-persisted-store'; export interface ThemeSetting { @@ -150,48 +152,12 @@ export const alwaysLoadOriginalVideo = persisted('always-load-original- export const recentAlbumsDropdown = persisted('recent-albums-open', true, {}); -export interface DuplicateSettings { - synchronizeAlbums: boolean; - synchronizeVisibility: boolean; - synchronizeFavorites: boolean; - synchronizeRating: boolean; - synchronizeDescription: boolean; - synchronizeLocation: boolean; - synchronizeTags: boolean; - /** @deprecated typo retained for migration */ - synchronizeDescpription?: boolean; -} - -const defaultDuplicateSettings: DuplicateSettings = { - synchronizeAlbums: true, - synchronizeVisibility: true, - synchronizeFavorites: true, - synchronizeRating: true, - synchronizeDescription: true, - synchronizeLocation: true, - synchronizeTags: true, -}; - -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, - }, +export const duplicateSettings = new PersistedLocalStorage('duplicate-settings', { + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: false, }); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 134ac17b60685..890f2eeaf384f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -40,3 +40,13 @@ export enum OnboardingRole { SERVER = 'server', USER = 'user', } + +export type DuplicateSettings = { + syncAlbums: boolean; + syncVisibility: boolean; + syncFavorites: boolean; + syncRating: boolean; + syncDescription: boolean; + syncLocation: boolean; + syncTags: boolean; +}; 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 fc1d157cec17f..63c0dead881ce 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,9 +3,9 @@ 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 { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import DuplicateSettingsModal from '$lib/modals/DuplicateSettingsModal.svelte'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { Route } from '$lib/route'; @@ -56,13 +56,6 @@ ], }; - const onShowSettings = async () => { - const settings = await modalManager.show(DuplicateSettingsModal, { settings: { ...$duplicateSettings } }); - if (settings) { - $duplicateSettings = settings; - } - }; - let duplicates = $state(data.duplicates); const { isViewing: showAssetViewer } = assetViewingStore; @@ -116,15 +109,7 @@ const response = await resolveDuplicates({ duplicateResolveDto: { groups: [{ duplicateId, keepAssetIds, trashAssetIds: trashIds }], - settings: { - synchronizeAlbums: $duplicateSettings.synchronizeAlbums, - synchronizeVisibility: $duplicateSettings.synchronizeVisibility, - synchronizeFavorites: $duplicateSettings.synchronizeFavorites, - synchronizeRating: $duplicateSettings.synchronizeRating, - synchronizeDescription: $duplicateSettings.synchronizeDescription, - synchronizeLocation: $duplicateSettings.synchronizeLocation, - synchronizeTags: $duplicateSettings.synchronizeTags, - }, + settings: duplicateSettings.current, }, }); @@ -184,15 +169,7 @@ trashAssetIds: group.assets.map((asset) => asset.id).filter((id) => !keepIds.has(id)), }; }), - settings: { - synchronizeAlbums: $duplicateSettings.synchronizeAlbums, - synchronizeVisibility: $duplicateSettings.synchronizeVisibility, - synchronizeFavorites: $duplicateSettings.synchronizeFavorites, - synchronizeRating: $duplicateSettings.synchronizeRating, - synchronizeDescription: $duplicateSettings.synchronizeDescription, - synchronizeLocation: $duplicateSettings.synchronizeLocation, - synchronizeTags: $duplicateSettings.synchronizeTags, - }, + settings: duplicateSettings.current, }, }); @@ -297,7 +274,7 @@ color="secondary" icon={mdiCogOutline} title={$t('settings')} - onclick={onShowSettings} + onclick={() => modalManager.show(DuplicateSettingsModal)} aria-label={$t('settings')} /> From b1dcd2d963c51076ba04fbbd0383911c5941307c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 2 Feb 2026 11:32:01 -0500 Subject: [PATCH 09/25] chore: clean up --- e2e/src/api/specs/duplicate.e2e-spec.ts | 198 ------------------ mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/duplicates_api.dart | 56 ----- mobile/openapi/lib/api_client.dart | 2 - .../lib/model/duplicate_stack_dto.dart | 128 ----------- open-api/immich-openapi-specs.json | 83 -------- open-api/typescript-sdk/src/fetch-client.ts | 38 +--- .../controllers/duplicate.controller.spec.ts | 47 +++++ .../src/controllers/duplicate.controller.ts | 25 +-- server/src/dtos/duplicate.dto.ts | 23 -- server/src/services/duplicate.service.ts | 49 +---- 12 files changed, 59 insertions(+), 593 deletions(-) delete mode 100644 mobile/openapi/lib/model/duplicate_stack_dto.dart create mode 100644 server/src/controllers/duplicate.controller.spec.ts diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts index 8d2627ecb525a..b8a80898bfa8d 100644 --- a/e2e/src/api/specs/duplicate.e2e-spec.ts +++ b/e2e/src/api/specs/duplicate.e2e-spec.ts @@ -29,13 +29,6 @@ describe('/duplicates', () => { }); 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') @@ -76,17 +69,6 @@ describe('/duplicates', () => { }); }); - 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) @@ -837,184 +819,4 @@ describe('/duplicates', () => { 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/mobile/openapi/README.md b/mobile/openapi/README.md index a8871046075cb..700c7d3674661 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -157,7 +157,6 @@ Class | Method | HTTP request | Description *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 @@ -429,7 +428,6 @@ Class | Method | HTTP request | Description - [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 4ce7668205b19..ff38b28784cab 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -167,7 +167,6 @@ 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 34bff3d31b8d7..bc541c6ce8379 100644 --- a/mobile/openapi/lib/api/duplicates_api.dart +++ b/mobile/openapi/lib/api/duplicates_api.dart @@ -219,60 +219,4 @@ class DuplicatesApi { } 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 fd4e07d0e5b6c..5a8b124c1b0a8 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -380,8 +380,6 @@ class ApiClient { 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_stack_dto.dart b/mobile/openapi/lib/model/duplicate_stack_dto.dart deleted file mode 100644 index df514d766c49a..0000000000000 --- a/mobile/openapi/lib/model/duplicate_stack_dto.dart +++ /dev/null @@ -1,128 +0,0 @@ -// -// 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 392ca4287faed..6b4459b304d96 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5326,62 +5326,6 @@ "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.", @@ -17980,33 +17924,6 @@ ], "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" - }, "EmailNotificationsResponse": { "properties": { "albumInvite": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9af66b9f91762..1412d83fc86a3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1207,21 +1207,6 @@ export type DuplicateResolveResponseDto = { /** 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 */ birthDate: string | null; @@ -2371,6 +2356,14 @@ 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[]; @@ -4554,21 +4547,6 @@ export function resolveDuplicates({ duplicateResolveDto }: { 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 */ diff --git a/server/src/controllers/duplicate.controller.spec.ts b/server/src/controllers/duplicate.controller.spec.ts new file mode 100644 index 0000000000000..66598b992018d --- /dev/null +++ b/server/src/controllers/duplicate.controller.spec.ts @@ -0,0 +1,47 @@ +import { DuplicateController } from 'src/controllers/duplicate.controller'; +import { DuplicateService } from 'src/services/duplicate.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(DuplicateController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(DuplicateService); + + beforeAll(async () => { + ctx = await controllerSetup(DuplicateController, [{ provide: DuplicateService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /duplicates', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/duplicates'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('DELETE /duplicates', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete('/duplicates'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('DELETE /duplicates/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/duplicates/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); + }); + }); +}); diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index a42169e4938c8..bd74b925164d7 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -3,13 +3,7 @@ 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 { - DuplicateResolveDto, - DuplicateResolveResponseDto, - DuplicateResponseDto, - DuplicateStackDto, -} from 'src/dtos/duplicate.dto'; -import { StackResponseDto } from 'src/dtos/stack.dto'; +import { DuplicateResolveDto, DuplicateResolveResponseDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; @@ -64,22 +58,7 @@ export class DuplicateController { '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 { + 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 3f2731da82906..7112b872ef77c 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -103,26 +103,3 @@ export class DuplicateResolveResponseDto { @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/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 17e45bdfc2519..0110cd9db9e5f 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -13,9 +13,7 @@ import { DuplicateResolveSettingsDto, DuplicateResolveStatus, DuplicateResponseDto, - DuplicateStackDto, } from 'src/dtos/duplicate.dto'; -import { mapStack, StackResponseDto } from 'src/dtos/stack.dto'; import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; @@ -200,9 +198,7 @@ export class DuplicateService extends BaseService { if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) { await this.albumRepository.addAssetIdsToAlbums( - [...allowedAlbumIds].flatMap((albumId) => - [...allowedShareIds].map((assetId) => ({ albumId, assetId })), - ), + [...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))), ); } } @@ -271,47 +267,6 @@ export class DuplicateService extends BaseService { }; } - async stack(auth: AuthDto, dto: DuplicateStackDto): Promise { - const { duplicateId, assetIds, primaryAssetId } = dto; - - // Step 1: Validate duplicate group exists and belongs to user - const duplicateGroup = await this.duplicateRepository.getByIdForUser(auth.user.id, duplicateId); - if (!duplicateGroup) { - throw new BadRequestException(`Duplicate group ${duplicateId} not found or access denied`); - } - - const groupAssetIds = new Set(duplicateGroup.assets.map((a) => a.id)); - - // Step 2: Validate all assetIds are members of the duplicate group - for (const assetId of assetIds) { - if (!groupAssetIds.has(assetId)) { - throw new BadRequestException(`Asset ${assetId} is not a member of duplicate group ${duplicateId}`); - } - } - - // Step 3: Pre-check permissions - await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: assetIds }); - - // Step 4: If primaryAssetId provided, validate and reorder - let orderedAssetIds = [...assetIds]; - if (primaryAssetId) { - if (!assetIds.includes(primaryAssetId)) { - throw new BadRequestException(`primaryAssetId must be in assetIds`); - } - // Reorder so primary is first - orderedAssetIds = [primaryAssetId, ...assetIds.filter((id) => id !== primaryAssetId)]; - } - - // Step 5: Create stack - const stack = await this.stackRepository.create({ ownerId: auth.user.id }, orderedAssetIds); - await this.eventRepository.emit('StackCreate', { stackId: stack.id, userId: auth.user.id }); - - // Step 6: Clear duplicate membership - await this.assetRepository.updateAll(assetIds, { duplicateId: null }); - - return mapStack(stack, { auth }); - } - @OnJob({ name: JobName.AssetDetectDuplicatesQueueAll, queue: QueueName.DuplicateDetection }) async handleQueueSearchDuplicates({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); From f601164a454a3204dbdb8dd123a0e750f64949d1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 2 Feb 2026 14:21:50 -0500 Subject: [PATCH 10/25] refactor: rename & clean up --- docs/docs/features/duplicates-utility.md | 17 +- e2e/src/api/specs/duplicate.e2e-spec.ts | 178 +----- mobile/openapi/README.md | 4 +- mobile/openapi/lib/api.dart | 4 +- mobile/openapi/lib/api/duplicates_api.dart | 9 +- mobile/openapi/lib/api_client.dart | 8 +- .../lib/model/bulk_id_error_reason.dart | 3 + .../lib/model/bulk_id_response_dto.dart | 22 +- .../lib/model/duplicate_resolve_dto.dart | 19 +- .../model/duplicate_resolve_group_dto.dart | 2 +- .../model/duplicate_resolve_response_dto.dart | 180 ------ .../model/duplicate_resolve_result_dto.dart | 201 ------- .../model/duplicate_resolve_settings_dto.dart | 154 ----- .../model/duplicate_sync_settings_dto.dart | 147 +++++ open-api/immich-openapi-specs.json | 136 ++--- open-api/typescript-sdk/src/fetch-client.ts | 50 +- .../src/controllers/duplicate.controller.ts | 8 +- server/src/dtos/asset-ids.response.dto.ts | 2 + server/src/dtos/duplicate.dto.ts | 121 ++-- server/src/queries/access.repository.sql | 10 + server/src/queries/duplicate.repository.sql | 5 +- server/src/repositories/access.repository.ts | 26 +- .../src/repositories/duplicate.repository.ts | 10 +- server/src/services/duplicate.service.spec.ts | 547 +++++++++++++----- server/src/services/duplicate.service.ts | 368 ++++++------ server/src/utils/access.ts | 5 + server/src/utils/duplicate-resolve.spec.ts | 324 ----------- server/src/utils/duplicate-resolve.ts | 197 ------- .../repositories/access.repository.mock.ts | 4 + .../[[assetId=id]]/+page.svelte | 18 +- 30 files changed, 999 insertions(+), 1780 deletions(-) delete mode 100644 mobile/openapi/lib/model/duplicate_resolve_response_dto.dart delete mode 100644 mobile/openapi/lib/model/duplicate_resolve_result_dto.dart delete mode 100644 mobile/openapi/lib/model/duplicate_resolve_settings_dto.dart create mode 100644 mobile/openapi/lib/model/duplicate_sync_settings_dto.dart delete mode 100644 server/src/utils/duplicate-resolve.spec.ts delete mode 100644 server/src/utils/duplicate-resolve.ts diff --git a/docs/docs/features/duplicates-utility.md b/docs/docs/features/duplicates-utility.md index 07b8ceada1523..8a1d9e05de8b7 100644 --- a/docs/docs/features/duplicates-utility.md +++ b/docs/docs/features/duplicates-utility.md @@ -10,11 +10,12 @@ The review duplicates page allows the user to individually select which assets s Additionally, there are synchronization settings that can be used to synchronize metadata between assets in the group. See the table below for more information about what metadata is available to synchronize. -| Name | Default | Description | -| ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Album | `false` | When enabled, the kept assets will be added to _every_ album that the other assets in the group belong to, including assets that are selected to be trashed. | -| Favorite | `false` | When enabled, if any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | -| Rating | `false` | When enabled, if one or more assets in the duplicate group have a rating, the highest rating is selected and then synchronized to the kept assets. | -| Description | `false` | When enabled, descriptions from each asset are combined together and then synchronized to all the kept assets. | -| Visibility | `false` | When enabled, the most restrictive visibility is applied the the kept assets. | -| Location | `false` | When enabled, latitude and longitude are only copied if among the group there is a single asset with geolocation data. | +| Name | Default | Description | +| ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Album | `false` | When enabled, the kept assets will be added to _every_ album that the other assets in the group belong to. | +| Favorite | `false` | When enabled, if any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | +| Rating | `false` | When enabled, if one or more assets in the duplicate group have a rating, the highest rating is selected and then synchronized to the kept assets. | +| Description | `false` | When enabled, descriptions from each asset are combined together and then synchronized to all the kept assets. | +| Visibility | `false` | When enabled, the most restrictive visibility is applied the the kept assets. | +| Location | `false` | When enabled, latitude and longitude are only copied if among the group there is a single asset with geolocation data. | +| Tag | `false` | When enabled, the kept assets will be tagged with _every_ tag that the other assets in the group were tagged with. | diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts index b8a80898bfa8d..e275d62452352 100644 --- a/e2e/src/api/specs/duplicate.e2e-spec.ts +++ b/e2e/src/api/specs/duplicate.e2e-spec.ts @@ -75,15 +75,6 @@ describe('/duplicates', () => { .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); @@ -96,15 +87,6 @@ describe('/duplicates', () => { .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); @@ -135,15 +117,6 @@ describe('/duplicates', () => { .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); @@ -182,15 +155,6 @@ describe('/duplicates', () => { .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); @@ -213,15 +177,6 @@ describe('/duplicates', () => { .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); @@ -246,15 +201,6 @@ describe('/duplicates', () => { .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); @@ -278,15 +224,6 @@ describe('/duplicates', () => { .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); @@ -309,15 +246,6 @@ describe('/duplicates', () => { .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); @@ -357,15 +285,6 @@ describe('/duplicates', () => { .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); @@ -394,15 +313,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncFavorites: true }, }); expect(status).toBe(200); @@ -432,15 +343,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncVisibility: true }, }); expect(status).toBe(200); @@ -473,15 +376,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncRating: true }, }); expect(status).toBe(200); @@ -514,15 +409,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncDescription: true }, }); expect(status).toBe(200); @@ -555,15 +442,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncLocation: true }, }); expect(status).toBe(200); @@ -601,15 +480,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncAlbums: true }, }); expect(status).toBe(200); @@ -657,15 +528,7 @@ describe('/duplicates', () => { .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, - }, + settings: { syncTags: true }, }); expect(status).toBe(200); @@ -701,15 +564,6 @@ describe('/duplicates', () => { { 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); @@ -751,15 +605,6 @@ describe('/duplicates', () => { .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); @@ -793,15 +638,6 @@ describe('/duplicates', () => { .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); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 700c7d3674661..eca28688bae87 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -424,10 +424,8 @@ Class | Method | HTTP request | Description - [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) + - [DuplicateSyncSettingsDto](doc//DuplicateSyncSettingsDto.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 ff38b28784cab..ecb821d733870 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -163,10 +163,8 @@ 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_sync_settings_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 bc541c6ce8379..7dd200157e680 100644 --- a/mobile/openapi/lib/api/duplicates_api.dart +++ b/mobile/openapi/lib/api/duplicates_api.dart @@ -205,7 +205,7 @@ class DuplicatesApi { /// Parameters: /// /// * [DuplicateResolveDto] duplicateResolveDto (required): - Future resolveDuplicates(DuplicateResolveDto duplicateResolveDto,) async { + Future?> resolveDuplicates(DuplicateResolveDto duplicateResolveDto,) async { final response = await resolveDuplicatesWithHttpInfo(duplicateResolveDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -214,8 +214,11 @@ class DuplicatesApi { // 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; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } return null; } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5a8b124c1b0a8..aad730e28fc08 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -372,14 +372,10 @@ class ApiClient { 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 'DuplicateSyncSettingsDto': + return DuplicateSyncSettingsDto.fromJson(value); case 'EmailNotificationsResponse': return EmailNotificationsResponse.fromJson(value); case 'EmailNotificationsUpdate': diff --git a/mobile/openapi/lib/model/bulk_id_error_reason.dart b/mobile/openapi/lib/model/bulk_id_error_reason.dart index ea56e9dbbaf4e..fd6c61d6fd834 100644 --- a/mobile/openapi/lib/model/bulk_id_error_reason.dart +++ b/mobile/openapi/lib/model/bulk_id_error_reason.dart @@ -27,6 +27,7 @@ class BulkIdErrorReason { static const noPermission = BulkIdErrorReason._(r'no_permission'); static const notFound = BulkIdErrorReason._(r'not_found'); static const unknown = BulkIdErrorReason._(r'unknown'); + static const validation = BulkIdErrorReason._(r'validation'); /// List of all possible values in this [enum][BulkIdErrorReason]. static const values = [ @@ -34,6 +35,7 @@ class BulkIdErrorReason { noPermission, notFound, unknown, + validation, ]; static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value); @@ -76,6 +78,7 @@ class BulkIdErrorReasonTypeTransformer { case r'no_permission': return BulkIdErrorReason.noPermission; case r'not_found': return BulkIdErrorReason.notFound; case r'unknown': return BulkIdErrorReason.unknown; + case r'validation': return BulkIdErrorReason.validation; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index cd122785dd2cb..1fa8536964a03 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -14,6 +14,7 @@ class BulkIdResponseDto { /// Returns a new [BulkIdResponseDto] instance. BulkIdResponseDto({ this.error, + this.errorMessage, required this.id, required this.success, }); @@ -21,6 +22,14 @@ class BulkIdResponseDto { /// Error reason if failed BulkIdResponseDtoErrorEnum? error; + /// + /// 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? errorMessage; + /// ID String id; @@ -30,6 +39,7 @@ class BulkIdResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is BulkIdResponseDto && other.error == error && + other.errorMessage == errorMessage && other.id == id && other.success == success; @@ -37,11 +47,12 @@ class BulkIdResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (error == null ? 0 : error!.hashCode) + + (errorMessage == null ? 0 : errorMessage!.hashCode) + (id.hashCode) + (success.hashCode); @override - String toString() => 'BulkIdResponseDto[error=$error, id=$id, success=$success]'; + String toString() => 'BulkIdResponseDto[error=$error, errorMessage=$errorMessage, id=$id, success=$success]'; Map toJson() { final json = {}; @@ -49,6 +60,11 @@ class BulkIdResponseDto { json[r'error'] = this.error; } else { // json[r'error'] = null; + } + if (this.errorMessage != null) { + json[r'errorMessage'] = this.errorMessage; + } else { + // json[r'errorMessage'] = null; } json[r'id'] = this.id; json[r'success'] = this.success; @@ -65,6 +81,7 @@ class BulkIdResponseDto { return BulkIdResponseDto( error: BulkIdResponseDtoErrorEnum.fromJson(json[r'error']), + errorMessage: mapValueOfType(json, r'errorMessage'), id: mapValueOfType(json, r'id')!, success: mapValueOfType(json, r'success')!, ); @@ -136,6 +153,7 @@ class BulkIdResponseDtoErrorEnum { static const noPermission = BulkIdResponseDtoErrorEnum._(r'no_permission'); static const notFound = BulkIdResponseDtoErrorEnum._(r'not_found'); static const unknown = BulkIdResponseDtoErrorEnum._(r'unknown'); + static const validation = BulkIdResponseDtoErrorEnum._(r'validation'); /// List of all possible values in this [enum][BulkIdResponseDtoErrorEnum]. static const values = [ @@ -143,6 +161,7 @@ class BulkIdResponseDtoErrorEnum { noPermission, notFound, unknown, + validation, ]; static BulkIdResponseDtoErrorEnum? fromJson(dynamic value) => BulkIdResponseDtoErrorEnumTypeTransformer().decode(value); @@ -185,6 +204,7 @@ class BulkIdResponseDtoErrorEnumTypeTransformer { case r'no_permission': return BulkIdResponseDtoErrorEnum.noPermission; case r'not_found': return BulkIdResponseDtoErrorEnum.notFound; case r'unknown': return BulkIdResponseDtoErrorEnum.unknown; + case r'validation': return BulkIdResponseDtoErrorEnum.validation; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/duplicate_resolve_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_dto.dart index a11e931d03bed..7009ce8fc9f4d 100644 --- a/mobile/openapi/lib/model/duplicate_resolve_dto.dart +++ b/mobile/openapi/lib/model/duplicate_resolve_dto.dart @@ -14,14 +14,20 @@ class DuplicateResolveDto { /// Returns a new [DuplicateResolveDto] instance. DuplicateResolveDto({ this.groups = const [], - required this.settings, + this.settings, }); /// List of duplicate groups to resolve List groups; /// Settings for synchronization behavior - DuplicateResolveSettingsDto settings; + /// + /// 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. + /// + DuplicateSyncSettingsDto? settings; @override bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveDto && @@ -32,7 +38,7 @@ class DuplicateResolveDto { int get hashCode => // ignore: unnecessary_parenthesis (groups.hashCode) + - (settings.hashCode); + (settings == null ? 0 : settings!.hashCode); @override String toString() => 'DuplicateResolveDto[groups=$groups, settings=$settings]'; @@ -40,7 +46,11 @@ class DuplicateResolveDto { Map toJson() { final json = {}; json[r'groups'] = this.groups; + if (this.settings != null) { json[r'settings'] = this.settings; + } else { + // json[r'settings'] = null; + } return json; } @@ -54,7 +64,7 @@ class DuplicateResolveDto { return DuplicateResolveDto( groups: DuplicateResolveGroupDto.listFromJson(json[r'groups']), - settings: DuplicateResolveSettingsDto.fromJson(json[r'settings'])!, + settings: DuplicateSyncSettingsDto.fromJson(json[r'settings']), ); } return null; @@ -103,7 +113,6 @@ class DuplicateResolveDto { /// 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 index 7a16d6303ddcd..94ca53eb7d0e4 100644 --- a/mobile/openapi/lib/model/duplicate_resolve_group_dto.dart +++ b/mobile/openapi/lib/model/duplicate_resolve_group_dto.dart @@ -20,7 +20,7 @@ class DuplicateResolveGroupDto { String duplicateId; - /// Asset IDs to keep (will have duplicateId cleared) + /// Asset IDs to keep List keepAssetIds; /// Asset IDs to trash or delete diff --git a/mobile/openapi/lib/model/duplicate_resolve_response_dto.dart b/mobile/openapi/lib/model/duplicate_resolve_response_dto.dart deleted file mode 100644 index 6f882da7c275d..0000000000000 --- a/mobile/openapi/lib/model/duplicate_resolve_response_dto.dart +++ /dev/null @@ -1,180 +0,0 @@ -// -// 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 deleted file mode 100644 index 984a80f49dc40..0000000000000 --- a/mobile/openapi/lib/model/duplicate_resolve_result_dto.dart +++ /dev/null @@ -1,201 +0,0 @@ -// -// 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 deleted file mode 100644 index 702d643a4ebe7..0000000000000 --- a/mobile/openapi/lib/model/duplicate_resolve_settings_dto.dart +++ /dev/null @@ -1,154 +0,0 @@ -// -// 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_sync_settings_dto.dart b/mobile/openapi/lib/model/duplicate_sync_settings_dto.dart new file mode 100644 index 0000000000000..ec5114e58488f --- /dev/null +++ b/mobile/openapi/lib/model/duplicate_sync_settings_dto.dart @@ -0,0 +1,147 @@ +// +// 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 DuplicateSyncSettingsDto { + /// Returns a new [DuplicateSyncSettingsDto] instance. + DuplicateSyncSettingsDto({ + this.syncAlbums = false, + this.syncDescription = false, + this.syncFavorites = false, + this.syncLocation = false, + this.syncRating = false, + this.syncTags = false, + this.syncVisibility = false, + }); + + /// Synchronize album membership across duplicate group + bool syncAlbums; + + /// Synchronize description across duplicate group + bool syncDescription; + + /// Synchronize favorite status across duplicate group + bool syncFavorites; + + /// Synchronize GPS location across duplicate group + bool syncLocation; + + /// Synchronize EXIF rating across duplicate group + bool syncRating; + + /// Synchronize tags across duplicate group + bool syncTags; + + /// Synchronize visibility (archive/timeline) across duplicate group + bool syncVisibility; + + @override + bool operator ==(Object other) => identical(this, other) || other is DuplicateSyncSettingsDto && + other.syncAlbums == syncAlbums && + other.syncDescription == syncDescription && + other.syncFavorites == syncFavorites && + other.syncLocation == syncLocation && + other.syncRating == syncRating && + other.syncTags == syncTags && + other.syncVisibility == syncVisibility; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (syncAlbums.hashCode) + + (syncDescription.hashCode) + + (syncFavorites.hashCode) + + (syncLocation.hashCode) + + (syncRating.hashCode) + + (syncTags.hashCode) + + (syncVisibility.hashCode); + + @override + String toString() => 'DuplicateSyncSettingsDto[syncAlbums=$syncAlbums, syncDescription=$syncDescription, syncFavorites=$syncFavorites, syncLocation=$syncLocation, syncRating=$syncRating, syncTags=$syncTags, syncVisibility=$syncVisibility]'; + + Map toJson() { + final json = {}; + json[r'syncAlbums'] = this.syncAlbums; + json[r'syncDescription'] = this.syncDescription; + json[r'syncFavorites'] = this.syncFavorites; + json[r'syncLocation'] = this.syncLocation; + json[r'syncRating'] = this.syncRating; + json[r'syncTags'] = this.syncTags; + json[r'syncVisibility'] = this.syncVisibility; + return json; + } + + /// Returns a new [DuplicateSyncSettingsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DuplicateSyncSettingsDto? fromJson(dynamic value) { + upgradeDto(value, "DuplicateSyncSettingsDto"); + if (value is Map) { + final json = value.cast(); + + return DuplicateSyncSettingsDto( + syncAlbums: mapValueOfType(json, r'syncAlbums') ?? false, + syncDescription: mapValueOfType(json, r'syncDescription') ?? false, + syncFavorites: mapValueOfType(json, r'syncFavorites') ?? false, + syncLocation: mapValueOfType(json, r'syncLocation') ?? false, + syncRating: mapValueOfType(json, r'syncRating') ?? false, + syncTags: mapValueOfType(json, r'syncTags') ?? false, + syncVisibility: mapValueOfType(json, r'syncVisibility') ?? false, + ); + } + 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 = DuplicateSyncSettingsDto.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 = DuplicateSyncSettingsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DuplicateSyncSettingsDto-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] = DuplicateSyncSettingsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6b4459b304d96..59c6cc21df900 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5290,7 +5290,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DuplicateResolveResponseDto" + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" } } }, @@ -5314,16 +5317,16 @@ ], "x-immich-history": [ { - "version": "v1", + "version": "v2.6.0", "state": "Added" }, { - "version": "v1", - "state": "Beta" + "version": "v2.6.0", + "state": "Alpha" } ], "x-immich-permission": "duplicate.delete", - "x-immich-state": "Beta" + "x-immich-state": "Alpha" } }, "/duplicates/{id}": { @@ -17242,7 +17245,8 @@ "duplicate", "no_permission", "not_found", - "unknown" + "unknown", + "validation" ], "type": "string" }, @@ -17254,10 +17258,14 @@ "duplicate", "no_permission", "not_found", - "unknown" + "unknown", + "validation" ], "type": "string" }, + "errorMessage": { + "type": "string" + }, "id": { "description": "ID", "type": "string" @@ -17764,15 +17772,14 @@ "settings": { "allOf": [ { - "$ref": "#/components/schemas/DuplicateResolveSettingsDto" + "$ref": "#/components/schemas/DuplicateSyncSettingsDto" } ], "description": "Settings for synchronization behavior" } }, "required": [ - "groups", - "settings" + "groups" ], "type": "object" }, @@ -17783,7 +17790,7 @@ "type": "string" }, "keepAssetIds": { - "description": "Asset IDs to keep (will have duplicateId cleared)", + "description": "Asset IDs to keep", "items": { "format": "uuid", "type": "string" @@ -17806,122 +17813,73 @@ ], "type": "object" }, - "DuplicateResolveResponseDto": { + "DuplicateResponseDto": { "properties": { - "results": { - "description": "Per-group results of the resolve operation", + "assets": { + "description": "Duplicate assets", "items": { - "$ref": "#/components/schemas/DuplicateResolveResultDto" + "$ref": "#/components/schemas/AssetResponseDto" }, "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", + "description": "Duplicate group ID", "type": "string" }, - "status": { - "description": "Status of the resolve operation for this group", - "enum": [ - "SUCCESS", - "FAILED" - ], - "type": "string" + "suggestedKeepAssetIds": { + "description": "Suggested asset IDs to keep based on file size and EXIF data", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" } }, "required": [ + "assets", "duplicateId", - "status" + "suggestedKeepAssetIds" ], "type": "object" }, - "DuplicateResolveSettingsDto": { + "DuplicateSyncSettingsDto": { "properties": { - "synchronizeAlbums": { + "syncAlbums": { + "default": false, "description": "Synchronize album membership across duplicate group", "type": "boolean" }, - "synchronizeDescription": { + "syncDescription": { + "default": false, "description": "Synchronize description across duplicate group", "type": "boolean" }, - "synchronizeFavorites": { + "syncFavorites": { + "default": false, "description": "Synchronize favorite status across duplicate group", "type": "boolean" }, - "synchronizeLocation": { + "syncLocation": { + "default": false, "description": "Synchronize GPS location across duplicate group", "type": "boolean" }, - "synchronizeRating": { + "syncRating": { + "default": false, "description": "Synchronize EXIF rating across duplicate group", "type": "boolean" }, - "synchronizeTags": { + "syncTags": { + "default": false, "description": "Synchronize tags across duplicate group", "type": "boolean" }, - "synchronizeVisibility": { + "syncVisibility": { + "default": false, "description": "Synchronize visibility (archive/timeline) across duplicate group", "type": "boolean" } }, - "required": [ - "synchronizeAlbums", - "synchronizeDescription", - "synchronizeFavorites", - "synchronizeLocation", - "synchronizeRating", - "synchronizeTags", - "synchronizeVisibility" - ], - "type": "object" - }, - "DuplicateResponseDto": { - "properties": { - "assets": { - "description": "Duplicate assets", - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - }, - "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" }, "EmailNotificationsResponse": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1412d83fc86a3..7b247afc8729c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -723,6 +723,7 @@ export type BulkIdsDto = { export type BulkIdResponseDto = { /** Error reason if failed */ error?: Error; + errorMessage?: string; /** ID */ id: string; /** Whether operation succeeded */ @@ -1166,46 +1167,32 @@ export type DuplicateResponseDto = { }; export type DuplicateResolveGroupDto = { duplicateId: string; - /** Asset IDs to keep (will have duplicateId cleared) */ + /** Asset IDs to keep */ keepAssetIds: string[]; /** Asset IDs to trash or delete */ trashAssetIds: string[]; }; -export type DuplicateResolveSettingsDto = { +export type DuplicateSyncSettingsDto = { /** Synchronize album membership across duplicate group */ - synchronizeAlbums: boolean; + syncAlbums?: boolean; /** Synchronize description across duplicate group */ - synchronizeDescription: boolean; + syncDescription?: boolean; /** Synchronize favorite status across duplicate group */ - synchronizeFavorites: boolean; + syncFavorites?: boolean; /** Synchronize GPS location across duplicate group */ - synchronizeLocation: boolean; + syncLocation?: boolean; /** Synchronize EXIF rating across duplicate group */ - synchronizeRating: boolean; + syncRating?: boolean; /** Synchronize tags across duplicate group */ - synchronizeTags: boolean; + syncTags?: boolean; /** Synchronize visibility (archive/timeline) across duplicate group */ - synchronizeVisibility: boolean; + syncVisibility?: 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; + settings?: DuplicateSyncSettingsDto; }; export type PersonResponseDto = { /** Person date of birth */ @@ -4540,7 +4527,7 @@ export function resolveDuplicates({ duplicateResolveDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: DuplicateResolveResponseDto; + data: BulkIdResponseDto[]; }>("/duplicates/resolve", oazapfts.json({ ...opts, method: "POST", @@ -6890,13 +6877,15 @@ export enum BulkIdErrorReason { Duplicate = "duplicate", NoPermission = "no_permission", NotFound = "not_found", - Unknown = "unknown" + Unknown = "unknown", + Validation = "validation" } export enum Error { Duplicate = "duplicate", NoPermission = "no_permission", NotFound = "not_found", - Unknown = "unknown" + Unknown = "unknown", + Validation = "validation" } export enum Permission { All = "all", @@ -7090,13 +7079,6 @@ 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 bd74b925164d7..32e26d64fc876 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -1,9 +1,9 @@ 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 { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DuplicateResolveDto, DuplicateResolveResponseDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto'; +import { DuplicateResolveDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; @@ -56,9 +56,9 @@ export class DuplicateController { 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'), + history: new HistoryBuilder().added('v2.6.0').alpha('v2.6.0'), }) - resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise { + resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise { return this.service.resolve(auth, dto); } } diff --git a/server/src/dtos/asset-ids.response.dto.ts b/server/src/dtos/asset-ids.response.dto.ts index 427117518d43a..1065d8485e804 100644 --- a/server/src/dtos/asset-ids.response.dto.ts +++ b/server/src/dtos/asset-ids.response.dto.ts @@ -23,6 +23,7 @@ export enum BulkIdErrorReason { NO_PERMISSION = 'no_permission', NOT_FOUND = 'not_found', UNKNOWN = 'unknown', + VALIDATION = 'validation', } export class BulkIdsDto { @@ -37,4 +38,5 @@ export class BulkIdResponseDto { success!: boolean; @ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason }) error?: BulkIdErrorReason; + errorMessage?: string; } diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 7112b872ef77c..01bdde0d2158c 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayMinSize, IsBoolean, ValidateNested } from 'class-validator'; +import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DuplicateResponseDto { @ApiProperty({ description: 'Duplicate group ID' }) @@ -10,96 +10,83 @@ export class DuplicateResponseDto { @ApiProperty({ description: 'Duplicate assets' }) assets!: AssetResponseDto[]; - @ApiProperty({ - description: 'Suggested asset IDs to keep based on file size and EXIF data', - isArray: true, - type: String, - }) + @ValidateUUID({ each: true, description: 'Suggested asset IDs to keep based on file size and EXIF data' }) suggestedKeepAssetIds!: string[]; } -// Resolve endpoint DTOs - -export class DuplicateResolveSettingsDto { - @ApiProperty({ type: Boolean, description: 'Synchronize album membership across duplicate group' }) - @IsBoolean() - synchronizeAlbums!: boolean; +export class DuplicateSyncSettingsDto { + @ValidateBoolean({ + description: 'Synchronize album membership across duplicate group', + default: false, + optional: true, + }) + syncAlbums?: boolean; - @ApiProperty({ type: Boolean, description: 'Synchronize visibility (archive/timeline) across duplicate group' }) - @IsBoolean() - synchronizeVisibility!: boolean; + @ValidateBoolean({ + description: 'Synchronize visibility (archive/timeline) across duplicate group', + default: false, + optional: true, + }) + syncVisibility?: boolean; - @ApiProperty({ type: Boolean, description: 'Synchronize favorite status across duplicate group' }) - @IsBoolean() - synchronizeFavorites!: boolean; + @ValidateBoolean({ + description: 'Synchronize favorite status across duplicate group', + default: false, + optional: true, + }) + syncFavorites?: boolean; - @ApiProperty({ type: Boolean, description: 'Synchronize EXIF rating across duplicate group' }) - @IsBoolean() - synchronizeRating!: boolean; + @ValidateBoolean({ + description: 'Synchronize EXIF rating across duplicate group', + default: false, + optional: true, + }) + syncRating?: boolean; - @ApiProperty({ type: Boolean, description: 'Synchronize description across duplicate group' }) - @IsBoolean() - synchronizeDescription!: boolean; + @ValidateBoolean({ + description: 'Synchronize description across duplicate group', + default: false, + optional: true, + }) + syncDescription?: boolean; - @ApiProperty({ type: Boolean, description: 'Synchronize GPS location across duplicate group' }) - @IsBoolean() - synchronizeLocation!: boolean; + @ValidateBoolean({ + description: 'Synchronize GPS location across duplicate group', + default: false, + optional: true, + }) + syncLocation?: boolean; - @ApiProperty({ type: Boolean, description: 'Synchronize tags across duplicate group' }) - @IsBoolean() - synchronizeTags!: boolean; + @ValidateBoolean({ + description: 'Synchronize tags across duplicate group', + default: false, + optional: true, + }) + syncTags?: boolean; } export class DuplicateResolveGroupDto { @ValidateUUID() duplicateId!: string; - @ApiProperty({ isArray: true, type: String, description: 'Asset IDs to keep (will have duplicateId cleared)' }) - @ValidateUUID({ each: true }) + @ValidateUUID({ each: true, description: 'Asset IDs to keep' }) keepAssetIds!: string[]; - @ApiProperty({ isArray: true, type: String, description: 'Asset IDs to trash or delete' }) - @ValidateUUID({ each: true }) + @ValidateUUID({ each: true, description: 'Asset IDs to trash or delete' }) trashAssetIds!: string[]; } export class DuplicateResolveDto { - @ApiProperty({ type: [DuplicateResolveGroupDto], description: 'List of duplicate groups to resolve' }) + @ApiProperty({ description: 'List of duplicate groups to resolve' }) @ValidateNested({ each: true }) + @IsArray() @Type(() => DuplicateResolveGroupDto) @ArrayMinSize(1) groups!: DuplicateResolveGroupDto[]; - @ApiProperty({ type: DuplicateResolveSettingsDto, description: 'Settings for synchronization behavior' }) + @ApiProperty({ 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[]; + @Optional() + @Type(() => DuplicateSyncSettingsDto) + settings?: DuplicateSyncSettingsDto; } diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 1239260dcedb1..810229093b611 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -160,6 +160,16 @@ where "session"."userId" = $1 and "session"."id" in ($2) +-- AccessRepository.duplicate.checkOwnerAccess +select + "asset"."duplicateId" +from + "asset" +where + "asset"."duplicateId" in ($1) + and "asset"."ownerId" = $2 + and "asset"."deletedAt" is null + -- AccessRepository.memory.checkOwnerAccess select "memory"."id" diff --git a/server/src/queries/duplicate.repository.sql b/server/src/queries/duplicate.repository.sql index 68ef32c20be4c..24a02e0f2341d 100644 --- a/server/src/queries/duplicate.repository.sql +++ b/server/src/queries/duplicate.repository.sql @@ -81,7 +81,7 @@ from where "asset"."duplicateId" = "singletons"."duplicateId" --- DuplicateRepository.getByIdForUser +-- DuplicateRepository.get select "asset"."duplicateId", json_agg( @@ -121,8 +121,7 @@ from ) as "asset2" on true where "asset"."visibility" in ('archive', 'timeline') - and "asset"."ownerId" = $1::uuid - and "asset"."duplicateId" = $2::uuid + and "asset"."duplicateId" = $1::uuid and "asset"."deletedAt" is null and "asset"."stackId" is null group by diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 533e74a3118f3..1661e42c146e7 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Kysely, sql } from 'kysely'; +import { Kysely, NotNull, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumUserRole, AssetVisibility } from 'src/enum'; @@ -285,6 +285,28 @@ class AuthDeviceAccess { } } +class DuplicateAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, duplicateIds: Set) { + if (duplicateIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('asset') + .select('asset.duplicateId') + .where('asset.duplicateId', 'in', [...duplicateIds]) + .where('asset.ownerId', '=', userId) + .where('asset.deletedAt', 'is', null) + .$narrowType<{ duplicateId: NotNull }>() + .execute() + .then((assets) => new Set(assets.map((asset) => asset.duplicateId))); + } +} + class NotificationAccess { constructor(private db: Kysely) {} @@ -488,6 +510,7 @@ export class AccessRepository { album: AlbumAccess; asset: AssetAccess; authDevice: AuthDeviceAccess; + duplicate: DuplicateAccess; memory: MemoryAccess; notification: NotificationAccess; person: PersonAccess; @@ -503,6 +526,7 @@ export class AccessRepository { this.album = new AlbumAccess(db); this.asset = new AssetAccess(db); this.authDevice = new AuthDeviceAccess(db); + this.duplicate = new DuplicateAccess(db); this.memory = new MemoryAccess(db); this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index b2625183f84c1..6d962f3a61561 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -105,10 +105,7 @@ export class DuplicateRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - async getByIdForUser( - userId: string, - duplicateId: string, - ): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> { + async get(duplicateId: string): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> { const result = await this.db .selectFrom('asset') .$call(withDefaultVisibility) @@ -136,10 +133,7 @@ export class DuplicateRepository { (join) => join.onTrue(), ) .select('asset.duplicateId') - .select((eb) => - eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets'), - ) - .where('asset.ownerId', '=', asUuid(userId)) + .select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets')) .where('asset.duplicateId', '=', asUuid(duplicateId)) .where('asset.deletedAt', 'is', null) .where('asset.stackId', 'is', null) diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 890bac1e3689a..45be095294bce 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,10 +1,10 @@ +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; -import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; -import { beforeEach, vitest } from 'vitest'; +import { beforeEach, describe, expect, it, vitest } from 'vitest'; vitest.useFakeTimers(); @@ -24,7 +24,7 @@ const hasDupe = { duplicateId: 'duplicate-id', }; -describe(SearchService.name, () => { +describe(DuplicateService.name, () => { let sut: DuplicateService; let mocks: ServiceMocks; @@ -38,6 +38,7 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['duplicate-id'])); mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue(); mocks.duplicateRepository.getAll.mockResolvedValue([ { @@ -154,148 +155,97 @@ describe(SearchService.name, () => { }); describe('resolve', () => { - it('should return COMPLETED status even with all failures', async () => { - const result = await sut.resolve(authStub.admin, { - groups: [{ duplicateId: 'fake-id', keepAssetIds: [], trashAssetIds: [] }], - settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, - }, - }); - - expect(result.status).toBe('COMPLETED'); - expect(result.results).toHaveLength(1); - expect(result.results[0].status).toBe('FAILED'); - }); - it('should handle mixed success and failure', async () => { - // First group: missing group (will fail) - mocks.duplicateRepository.getByIdForUser.mockResolvedValueOnce(undefined); - - // Second group: invalid inputs (will also fail) - mocks.duplicateRepository.getByIdForUser.mockResolvedValueOnce({ + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2'])); + mocks.duplicateRepository.get.mockResolvedValueOnce(void 0); + mocks.duplicateRepository.get.mockResolvedValueOnce({ duplicateId: 'group-2', assets: [{ ...assetStub.image, id: 'asset-1' }], }); - const result = await sut.resolve(authStub.admin, { - groups: [ - { duplicateId: 'fake-id', keepAssetIds: [], trashAssetIds: [] }, - { duplicateId: 'group-2', keepAssetIds: ['non-member-id'], trashAssetIds: [] }, - ], - settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, - }, - }); - - expect(result.status).toBe('COMPLETED'); - expect(result.results).toHaveLength(2); - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[1].status).toBe('FAILED'); + await expect( + sut.resolve(authStub.admin, { + groups: [ + { duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }, + { duplicateId: 'group-2', keepAssetIds: ['asset-1'], trashAssetIds: [] }, + ], + }), + ).resolves.toEqual([ + { id: 'group-1', success: false, error: BulkIdErrorReason.NOT_FOUND }, + { id: 'group-2', success: true }, + ]); }); - it('should catch and report errors in resolveGroup', async () => { - mocks.duplicateRepository.getByIdForUser.mockRejectedValue(new Error('Database error')); - - const result = await sut.resolve(authStub.admin, { - groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }], - settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, - }, - }); + it('should catch and report errors', async () => { + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockRejectedValue(new Error('Database error')); - expect(result.status).toBe('COMPLETED'); - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('Database error'); + await expect( + sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }], + }), + ).resolves.toEqual([{ id: 'group-1', success: false, error: BulkIdErrorReason.UNKNOWN }]); }); }); describe('resolveGroup (via resolve)', () => { it('should fail if duplicate group not found', async () => { - mocks.duplicateRepository.getByIdForUser.mockResolvedValue(undefined); - - const result = await sut.resolve(authStub.admin, { - groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }], - settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['missing-id'])); + mocks.duplicateRepository.get.mockResolvedValue(void 0); + + await expect( + sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }], + settings: { + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: false, + }, + }), + ).resolves.toEqual([ + { + id: 'missing-id', + success: false, + error: BulkIdErrorReason.NOT_FOUND, }, - }); - - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('not found or access denied'); + ]); }); - it('should fail if keepAssetIds contains non-member', async () => { - mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + it('should skip when keepAssetIds contains non-member', async () => { + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', assets: [{ ...assetStub.image, id: 'asset-1' }], }); - const result = await sut.resolve(authStub.admin, { - groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-999'], trashAssetIds: [] }], - settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, - }, - }); - - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('not a member of duplicate group'); + await expect( + sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-999', 'asset-1'], trashAssetIds: [] }], + }), + ).resolves.toEqual([{ id: 'group-1', success: true }]); }); - it('should fail if trashAssetIds contains non-member', async () => { - mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + it('should skip when trashAssetIds contains non-member', async () => { + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', assets: [{ ...assetStub.image, id: 'asset-1' }], }); - const result = await sut.resolve(authStub.admin, { - groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: ['asset-999'] }], - settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, - }, - }); - - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('not a member of duplicate group'); + await expect( + sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-999'] }], + }), + ).resolves.toEqual([{ id: 'group-1', success: true }]); }); it('should fail if keepAssetIds and trashAssetIds overlap', async () => { - mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', assets: [ { ...assetStub.image, id: 'asset-1' }, @@ -306,22 +256,23 @@ describe(SearchService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-1'] }], settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: false, }, }); - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('disjoint'); + expect(result[0].success).toBe(false); + expect(result[0].errorMessage).toContain('An asset cannot be in both keepAssetIds and trashAssetIds'); }); it('should fail if keepAssetIds and trashAssetIds do not cover all assets', async () => { - mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', assets: [ { ...assetStub.image, id: 'asset-1' }, @@ -333,22 +284,23 @@ describe(SearchService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-2'] }], settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: false, }, }); - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('must cover all assets'); + expect(result[0].success).toBe(false); + expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds'); }); it('should fail if partial trash without keepers', async () => { - mocks.duplicateRepository.getByIdForUser.mockResolvedValue({ + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', assets: [ { ...assetStub.image, id: 'asset-1' }, @@ -359,18 +311,18 @@ describe(SearchService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: ['asset-1'] }], settings: { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: false, }, }); - expect(result.results[0].status).toBe('FAILED'); - expect(result.results[0].reason).toContain('must cover all assets'); + expect(result[0].success).toBe(false); + expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds'); }); // NOTE: The following integration-style tests are covered by E2E tests instead @@ -535,3 +487,308 @@ describe(SearchService.name, () => { }); }); }); + +// TODO: fix these tests + +// const allDisabledSettings: DuplicateSyncSettingsDto = { +// syncAlbums: false, +// syncVisibility: false, +// syncFavorites: false, +// syncRating: false, +// syncDescription: false, +// syncLocation: false, +// syncTags: false, +// }; + +// describe('duplicate-resolve utils', () => { +// describe('getSyncedInfo', () => { +// it('should return defaults for empty list', () => { +// const result = getSyncedInfo([]); +// expect(result).toEqual({ +// isFavorite: false, +// visibility: undefined, +// rating: 0, +// description: null, +// latitude: null, +// longitude: null, +// tagIds: [], +// }); +// }); + +// describe('isFavorite', () => { +// it('should return false if no assets are favorite', () => { +// const assets = [factory.asset({ isFavorite: false }), factory.asset({ isFavorite: false })]; +// expect(getSyncedInfo(assets).isFavorite).toBe(false); +// }); + +// it('should return true if any asset is favorite', () => { +// const assets = [factory.asset({ isFavorite: false }), factory.asset({ isFavorite: true })]; +// expect(getSyncedInfo(assets).isFavorite).toBe(true); +// }); +// }); + +// describe('visibility', () => { +// it('should return undefined if no special visibility', () => { +// const assets = [factory.asset({ visibility: AssetVisibility.Timeline })]; +// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Timeline); +// }); + +// it('should prioritize Locked over Archive and Timeline', () => { +// const assets = [ +// factory.asset({ visibility: AssetVisibility.Timeline }), +// factory.asset({ visibility: AssetVisibility.Archive }), +// factory.asset({ visibility: AssetVisibility.Locked }), +// ]; +// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Locked); +// }); + +// it('should prioritize Archive over Timeline', () => { +// const assets = [ +// factory.asset({ visibility: AssetVisibility.Timeline }), +// factory.asset({ visibility: AssetVisibility.Archive }), +// ]; +// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Archive); +// }); + +// it('should use Hidden if no standard visibility but Hidden is present', () => { +// const assets = [factory.asset({ visibility: AssetVisibility.Hidden })]; +// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Hidden); +// }); +// }); + +// describe('rating', () => { +// it('should return 0 if no ratings', () => { +// const assets = [factory.asset(), factory.asset()]; +// expect(getSyncedInfo(assets).rating).toBe(0); +// }); + +// it('should return max rating', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ rating: 3 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ rating: 5 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ rating: 1 }) }, +// ]; +// expect(getSyncedInfo(assets).rating).toBe(5); +// }); +// }); + +// describe('description', () => { +// it('should return null if no descriptions', () => { +// expect(getSyncedInfo([factory.asset(), factory.asset()]).description).toBeNull(); +// }); + +// it('should concatenate unique non-empty lines', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ description: 'Line 1\nLine 2' }) }, +// { ...factory.asset(), exifInfo: factory.exif({ description: 'Line 2\nLine 3' }) }, +// ]; +// expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2\nLine 3'); +// }); + +// it('should trim lines and skip empty', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ description: ' Line 1 \n\n Line 2 \n ' }) }, +// ]; +// expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2'); +// }); +// }); + +// describe('location', () => { +// it('should return null if no location data', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif() }, +// { ...factory.asset(), exifInfo: factory.exif() }, +// ]; +// const result = getSyncedInfo(assets); +// expect(result.latitude).toBeNull(); +// expect(result.longitude).toBeNull(); +// }); + +// it('should return coordinates if all assets have same location', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// ]; +// const result = getSyncedInfo(assets); +// expect(result.latitude).toBe(40.7128); +// expect(result.longitude).toBe(-74.006); +// }); + +// it('should return null if locations differ', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 34.0522, longitude: -118.2437 }) }, +// ]; +// const result = getSyncedInfo(assets); +// expect(result.latitude).toBeNull(); +// expect(result.longitude).toBeNull(); +// }); + +// it('should ignore assets with missing location', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// { ...factory.asset(), exifInfo: factory.exif() }, +// ]; +// const result = getSyncedInfo(assets); +// expect(result.latitude).toBe(40.7128); +// expect(result.longitude).toBe(-74.006); +// }); +// }); + +// describe('tagIds', () => { +// it('should return empty array if no tags', () => { +// const assets = [ +// { ...factory.asset(), tags: [] }, +// { ...factory.asset(), tags: [] }, +// ]; +// expect(getSyncedInfo(assets).tagIds).toEqual([]); +// }); + +// it('should collect unique tag IDs from all assets', () => { +// const assets = [ +// { +// ...factory.asset(), +// tags: [factory.tag({ id: 'tag-1', value: 'tag-1' })], +// }, +// { +// ...factory.asset(), +// tags: [factory.tag({ id: 'tag-1', value: 'tag-1' }), factory.tag({ id: 'tag-2', value: 'tag-2' })], +// }, +// ]; +// const result = getSyncedInfo(assets); +// expect(result.tagIds).toHaveLength(2); +// expect(result.tagIds).toContain('tag-1'); +// expect(result.tagIds).toContain('tag-2'); +// }); +// }); +// }); + +// describe('computeResolvePolicy', () => { +// it('should always set duplicateId to null in assetBulkUpdate', () => { +// const assets = [factory.asset(), factory.asset()]; +// const policy = getSyncMergeResult(assets, ['1'], allDisabledSettings); +// expect(policy.assetBulkUpdate.duplicateId).toBeNull(); +// }); + +// it('should set ids to idsToKeep', () => { +// const assets = [factory.asset(), factory.asset()]; +// const policy = getSyncMergeResult(assets, ['1', '2'], allDisabledSettings); +// expect(policy.assetBulkUpdate.ids).toEqual(['1', '2']); +// }); + +// it('should not set sync fields when all settings disabled', () => { +// const assets = [ +// { +// ...factory.asset({ +// isFavorite: true, +// visibility: AssetVisibility.Archive, +// }), +// exifInfo: factory.exif({ rating: 5, description: 'test' }), +// }, +// ]; +// const policy = getSyncMergeResult(assets, ['1'], allDisabledSettings); + +// expect(policy.assetBulkUpdate.isFavorite).toBeUndefined(); +// expect(policy.assetBulkUpdate.visibility).toBeUndefined(); +// expect(policy.assetBulkUpdate.rating).toBeUndefined(); +// expect(policy.assetBulkUpdate.description).toBeUndefined(); +// expect(policy.mergedAlbumIds).toEqual([]); +// expect(policy.mergedTagIds).toEqual([]); +// }); + +// it('should set isFavorite when syncFavorites enabled', () => { +// const assets = [{ ...factory.asset({ isFavorite: true }) }, { ...factory.asset({ isFavorite: false }) }]; +// const settings = { ...allDisabledSettings, syncFavorites: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.isFavorite).toBe(true); +// }); + +// it('should set visibility when syncVisibility enabled', () => { +// const assets = [ +// { ...factory.asset({ visibility: AssetVisibility.Archive }) }, +// { ...factory.asset({ visibility: AssetVisibility.Timeline }) }, +// ]; +// const settings = { ...allDisabledSettings, syncVisibility: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.visibility).toBe(AssetVisibility.Archive); +// }); + +// it('should set rating when syncRating enabled', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ rating: 3 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ rating: 5 }) }, +// ]; +// const settings = { ...allDisabledSettings, syncRating: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.rating).toBe(5); +// }); + +// it('should set description when syncDescription enabled and non-null', () => { +// const assets = [{ ...factory.asset(), exifInfo: factory.exif({ description: 'Test description' }) }]; +// const settings = { ...allDisabledSettings, syncDescription: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.description).toBe('Test description'); +// }); + +// it('should not set description when null', () => { +// const assets = [factory.asset()]; +// const settings = { ...allDisabledSettings, syncDescription: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.description).toBeUndefined(); +// }); + +// it('should set location when syncLocation enabled and coordinates match', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// ]; +// const settings = { ...allDisabledSettings, syncLocation: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.latitude).toBe(40.7128); +// expect(policy.assetBulkUpdate.longitude).toBe(-74.006); +// }); + +// it('should not set location when coordinates differ', () => { +// const assets = [ +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, +// { ...factory.asset(), exifInfo: factory.exif({ latitude: 34.0522, longitude: -118.2437 }) }, +// ]; +// const settings = { ...allDisabledSettings, syncLocation: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.assetBulkUpdate.latitude).toBeUndefined(); +// expect(policy.assetBulkUpdate.longitude).toBeUndefined(); +// }); + +// it('should return merged album IDs when syncAlbums enabled', () => { +// const assets = [factory.asset(), factory.asset()]; +// const settings = { ...allDisabledSettings, syncAlbums: true }; +// const assetAlbumMap = new Map([ +// ['1', ['album-1', 'album-2']], +// ['2', ['album-2', 'album-3']], +// ]); +// const policy = getSyncMergeResult(assets, ['1'], settings, assetAlbumMap); +// expect(policy.mergedAlbumIds).toHaveLength(3); +// expect(policy.mergedAlbumIds).toContain('album-1'); +// expect(policy.mergedAlbumIds).toContain('album-2'); +// expect(policy.mergedAlbumIds).toContain('album-3'); +// }); + +// it('should return merged tag IDs when syncTags enabled', () => { +// const assets = [ +// { +// ...factory.asset({}), +// tags: [factory.tag({ id: 'tag-1', value: 'tag-1' })], +// }, +// { +// ...factory.asset({}), +// tags: [factory.tag({ id: 'tag-2', value: 'tag-2' })], +// }, +// ]; +// const settings = { ...allDisabledSettings, syncTags: true }; +// const policy = getSyncMergeResult(assets, ['1'], settings); +// expect(policy.mergedTagIds).toHaveLength(2); +// expect(policy.mergedTagIds).toContain('tag-1'); +// expect(policy.mergedTagIds).toContain('tag-2'); +// }); +// }); +// }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 0110cd9db9e5f..6713567ef6190 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,29 +1,72 @@ import { Injectable } from '@nestjs/common'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnJob } from 'src/decorators'; -import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { mapAsset } from 'src/dtos/asset-response.dto'; +import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { - DuplicateResolveBatchStatus, DuplicateResolveDto, DuplicateResolveGroupDto, - DuplicateResolveResponseDto, - DuplicateResolveResultDto, - DuplicateResolveSettingsDto, - DuplicateResolveStatus, DuplicateResponseDto, + DuplicateSyncSettingsDto, } from 'src/dtos/duplicate.dto'; import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { updateLockedColumns } from 'src/utils/database'; import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate'; -import { computeResolvePolicy } from 'src/utils/duplicate-resolve'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; -// Fields that are stored in the exif table rather than the asset table -const EXIF_FIELDS = ['latitude', 'longitude', 'rating', 'description'] as const; +type ResolveRequest = { + assetUpdate: { + isFavorite?: boolean; + visibility?: AssetVisibility; + description?: string; + }; + + exifUpdate: { + rating?: number; + latitude?: number; + longitude?: number; + }; + + mergedAlbumIds: string[]; + + mergedTagIds: string[]; +}; + +const uniqueNonEmptyLines = (values: Array): string[] => { + const unique = new Set(); + 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 getUniqueCoordinate = (assets: MapAsset[], 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 Set(values); + return unique.size === 1 ? [...unique][0] : null; +}; @Injectable() export class DuplicateService extends BaseService { @@ -50,221 +93,224 @@ export class DuplicateService extends BaseService { await this.duplicateRepository.deleteAll(auth.user.id, dto.ids); } - async resolve(auth: AuthDto, dto: DuplicateResolveDto): Promise { - const results: DuplicateResolveResultDto[] = []; + async resolve(auth: AuthDto, dto: DuplicateResolveDto) { + const duplicateIds = dto.groups.map(({ duplicateId }) => duplicateId); + + await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: duplicateIds }); + + const results: BulkIdResponseDto[] = []; for (const group of dto.groups) { try { - const result = await this.resolveGroup(auth, group, dto.settings); - results.push(result); - } catch (error) { - results.push({ - duplicateId: group.duplicateId, - status: DuplicateResolveStatus.Failed, - reason: `Failed to resolve duplicate group: ${error instanceof Error ? error.message : String(error)}`, - }); + results.push(await this.resolveGroup(auth, group, dto.settings || {})); + } catch (error: Error | any) { + this.logger.error(`Error resolving duplicate group ${group.duplicateId}: ${error}`, error?.stack); + results.push({ id: group.duplicateId, success: false, error: BulkIdErrorReason.UNKNOWN }); } } - return { - status: DuplicateResolveBatchStatus.Completed, - results, - }; + return results; } private async resolveGroup( auth: AuthDto, group: DuplicateResolveGroupDto, - settings: DuplicateResolveSettingsDto, - ): Promise { + settings: DuplicateSyncSettingsDto, + ): Promise { const { duplicateId, keepAssetIds, trashAssetIds } = group; - // Step 1: Validate group ownership and membership - const duplicateGroup = await this.duplicateRepository.getByIdForUser(auth.user.id, duplicateId); + const duplicateGroup = await this.duplicateRepository.get(duplicateId); if (!duplicateGroup) { - return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: `Duplicate group ${duplicateId} not found or access denied`, - }; + return { id: duplicateId, success: false, error: BulkIdErrorReason.NOT_FOUND }; } const groupAssetIds = new Set(duplicateGroup.assets.map((a) => a.id)); - const mappedAssets = duplicateGroup.assets.map((asset) => mapAsset(asset, { auth })); - // Validate all keepAssetIds are in the group - for (const assetId of keepAssetIds) { - if (!groupAssetIds.has(assetId)) { + // ignore/skip asset IDs not in the group + const idsToKeep = keepAssetIds.filter((id) => groupAssetIds.has(id)); + const idsToTrash = trashAssetIds.filter((id) => groupAssetIds.has(id)); + + for (const assetId of groupAssetIds) { + if (idsToKeep.includes(assetId) && idsToTrash.includes(assetId)) { return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: `Asset ${assetId} is not a member of duplicate group ${duplicateId}`, + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds', }; } - } - // Validate all trashAssetIds are in the group - for (const assetId of trashAssetIds) { - if (!groupAssetIds.has(assetId)) { + if (!idsToKeep.includes(assetId) && !idsToTrash.includes(assetId)) { return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: `Asset ${assetId} is not a member of duplicate group ${duplicateId}`, + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', }; } } - // Validate keepAssetIds and trashAssetIds are disjoint - const keepSet = new Set(keepAssetIds); - for (const assetId of trashAssetIds) { - if (keepSet.has(assetId)) { + if (idsToTrash.length > 0) { + const ids = await this.checkAccess({ auth, permission: Permission.AssetDelete, ids: idsToTrash }); + if (ids.size !== idsToTrash.length) { return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: 'keepAssetIds and trashAssetIds must be disjoint (no overlap)', + id: duplicateId, + success: false, + error: BulkIdErrorReason.NO_PERMISSION, + errorMessage: 'No permission to delete assets', }; } } - // Validate keepAssetIds and trashAssetIds cover all assets in the group - const coveredAssetIds = new Set([...keepAssetIds, ...trashAssetIds]); - if (coveredAssetIds.size !== groupAssetIds.size) { - return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: 'keepAssetIds and trashAssetIds must cover all assets in the duplicate group', - }; - } + const assetAlbumMap = settings.syncAlbums + ? await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]) + : new Map(); - // Step 2: Compute idsToKeep (validated above to cover all assets) - const idsToKeep = keepAssetIds; + const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds } = this.getSyncMergeResult( + duplicateGroup.assets, + settings, + assetAlbumMap, + ); - // Step 0 (delayed): Pre-check permissions before any database operations - // Check asset update permission for keepers - if (idsToKeep.length > 0) { - const allowedUpdateIds = await this.checkAccess({ + if (mergedAlbumIds.length > 0) { + const allowedAlbumIds = await this.checkAccess({ auth, - permission: Permission.AssetUpdate, + permission: Permission.AlbumAssetCreate, + ids: mergedAlbumIds, + }); + + const allowedShareIds = await this.checkAccess({ + auth, + permission: Permission.AssetShare, ids: idsToKeep, }); - if (allowedUpdateIds.size !== idsToKeep.length) { - return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: `Not found or no ${Permission.AssetUpdate} access`, - }; + + if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) { + await this.albumRepository.addAssetIdsToAlbums( + [...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))), + ); } } - // Check asset delete permission for trash assets - if (trashAssetIds.length > 0) { - const allowedDeleteIds = await this.checkAccess({ + if (mergedTagIds.length > 0) { + const allowedTagIds = await this.checkAccess({ auth, - permission: Permission.AssetDelete, - ids: trashAssetIds, + permission: Permission.TagAsset, + ids: mergedTagIds, }); - if (allowedDeleteIds.size !== trashAssetIds.length) { - return { - duplicateId, - status: DuplicateResolveStatus.Failed, - reason: `Not found or no ${Permission.AssetDelete} access`, - }; + + if (allowedTagIds.size > 0) { + // Replace tags for each keeper asset to ensure all merged tags are applied + await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds]))); } } - // Step 3: Fetch album IDs if synchronizeAlbums is enabled - const assetAlbumMap = settings.synchronizeAlbums - ? await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]) - : new Map(); - - // Step 4: Run resolve policy - const policy = computeResolvePolicy(mappedAssets, idsToKeep, settings, assetAlbumMap); - - // Step 5: Apply updates in order if (idsToKeep.length > 0) { - // 5a. Synchronize albums - if (settings.synchronizeAlbums && policy.mergedAlbumIds.length > 0) { - // Pre-check album permissions - const allowedAlbumIds = await this.checkAccess({ - auth, - permission: Permission.AlbumAssetCreate, - ids: policy.mergedAlbumIds, - }); - const allowedShareIds = await this.checkAccess({ - auth, - permission: Permission.AssetShare, - ids: idsToKeep, - }); - - if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) { - await this.albumRepository.addAssetIdsToAlbums( - [...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))), - ); - } - } - - // 5b. Synchronize tags - if (settings.synchronizeTags && policy.mergedTagIds.length > 0) { - const allowedTagIds = await this.checkAccess({ - auth, - permission: Permission.TagAsset, - ids: policy.mergedTagIds, - }); - - if (allowedTagIds.size > 0) { - // Replace tags for each keeper asset to ensure all merged tags are applied - await Promise.all( - idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])), - ); - } + if (Object.keys(exifUpdate).length > 0) { + await this.assetRepository.updateAllExif(idsToKeep, updateLockedColumns(exifUpdate)); + await this.jobRepository.queueAll(idsToKeep.map((id) => ({ name: JobName.SidecarWrite, data: { id } }))); } - // 5c. Update keeper assets - if (policy.assetBulkUpdate.ids.length > 0) { - const { ids, duplicateId: _updateDuplicateId, ...assetFields } = policy.assetBulkUpdate; - const exifFields: Record = {}; - const assetUpdateFields: Record = {}; - - // Separate exif fields from asset fields - for (const [key, value] of Object.entries(assetFields)) { - if (EXIF_FIELDS.includes(key as (typeof EXIF_FIELDS)[number])) { - exifFields[key] = value; - } else { - assetUpdateFields[key] = value; - } - } - - // Update exif fields if any - if (Object.keys(exifFields).length > 0) { - await this.assetRepository.updateAllExif(ids, exifFields); - } - - // Update asset fields including duplicateId: null - await this.assetRepository.updateAll(ids, { ...assetUpdateFields, duplicateId: null }); - } + await this.assetRepository.updateAll(idsToKeep, { duplicateId: null, ...assetUpdate }); } - // 5d/5e. Delete/trash assets - if (trashAssetIds.length > 0) { + if (idsToTrash.length > 0) { + // TODO: this is duplicated with AssetService.deleteAssets const { trash } = await this.getConfig({ withCache: true }); const force = !trash.enabled; - await this.assetRepository.updateAll(trashAssetIds, { + await this.assetRepository.updateAll(idsToTrash, { deletedAt: new Date(), status: force ? AssetStatus.Deleted : AssetStatus.Trashed, duplicateId: null, }); await this.eventRepository.emit(force ? 'AssetDeleteAll' : 'AssetTrashAll', { - assetIds: trashAssetIds, + assetIds: idsToTrash, userId: auth.user.id, }); } - return { - duplicateId, - status: DuplicateResolveStatus.Success, + return { id: duplicateId, success: true }; + } + + private getSyncMergeResult( + assets: MapAsset[], + settings: DuplicateSyncSettingsDto, + assetAlbumMap: Map = new Map(), + ): ResolveRequest { + const response: ResolveRequest = { + mergedAlbumIds: [], + mergedTagIds: [], + assetUpdate: {}, + exifUpdate: {}, }; + + if (settings.syncFavorites) { + response.assetUpdate.isFavorite = assets.some((asset) => asset.isFavorite); + } + + if (settings.syncVisibility) { + const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline]; + const visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); + if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) { + response.assetUpdate.visibility = visibility; + } + } + + if (settings.syncRating) { + let rating = 0; + for (const asset of assets) { + const assetRating = asset.exifInfo?.rating ?? 0; + if (assetRating > rating) { + rating = assetRating; + } + } + response.exifUpdate.rating = rating; + } + + if (settings.syncDescription) { + const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); + const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; + if (description !== null) { + response.assetUpdate.description = description; + } + } + + if (settings.syncLocation) { + const latitude = getUniqueCoordinate(assets, 'latitude'); + const longitude = getUniqueCoordinate(assets, 'longitude'); + if (latitude !== null && longitude !== null) { + response.exifUpdate.latitude = latitude; + response.exifUpdate.longitude = longitude; + } + } + + if (settings.syncAlbums) { + const albumIdSet = new Set(); + for (const [, albumIds] of assetAlbumMap) { + for (const albumId of albumIds) { + albumIdSet.add(albumId); + } + } + response.mergedAlbumIds = [...albumIdSet]; + } + + if (settings.syncTags) { + const tagIds = [ + ...new Set( + assets + .flatMap((asset) => asset.tags ?? []) + .map((tag) => tag.id) + .filter((id): id is string => !!id), + ), + ]; + if (tagIds.length > 0) { + response.mergedTagIds = settings.syncTags ? tagIds : []; + } + } + + return response; } @OnJob({ name: JobName.AssetDetectDuplicatesQueueAll, queue: QueueName.DuplicateDetection }) diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 7431cb3293602..e2d25aee396bd 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -229,6 +229,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } + case Permission.DuplicateRead: + case Permission.DuplicateDelete: { + return access.duplicate.checkOwnerAccess(auth.user.id, ids); + } + case Permission.AuthDeviceDelete: { return await access.authDevice.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/utils/duplicate-resolve.spec.ts b/server/src/utils/duplicate-resolve.spec.ts deleted file mode 100644 index bdf36b27eb133..0000000000000 --- a/server/src/utils/duplicate-resolve.spec.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { DuplicateResolveSettingsDto } from 'src/dtos/duplicate.dto'; -import { AssetType, AssetVisibility } from 'src/enum'; -import { computeResolvePolicy, getSyncedInfo } from 'src/utils/duplicate-resolve'; -import { describe, expect, it } from 'vitest'; - -const createAsset = ( - id: string, - overrides: Partial = {}, -): AssetResponseDto => ({ - id, - type: AssetType.Image, - thumbhash: null, - localDateTime: new Date(), - duration: '0:00:00.00000', - hasMetadata: true, - width: 1920, - height: 1080, - createdAt: new Date(), - deviceAssetId: 'device-asset-1', - deviceId: 'device-1', - ownerId: 'owner-1', - originalPath: '/path/to/asset', - originalFileName: 'asset.jpg', - fileCreatedAt: new Date(), - fileModifiedAt: new Date(), - updatedAt: new Date(), - isFavorite: false, - isArchived: false, - isTrashed: false, - isOffline: false, - visibility: AssetVisibility.Timeline, - checksum: 'checksum', - ...overrides, -}); - -const allDisabledSettings: DuplicateResolveSettingsDto = { - synchronizeAlbums: false, - synchronizeVisibility: false, - synchronizeFavorites: false, - synchronizeRating: false, - synchronizeDescription: false, - synchronizeLocation: false, - synchronizeTags: false, -}; - -const _allEnabledSettings: DuplicateResolveSettingsDto = { - synchronizeAlbums: true, - synchronizeVisibility: true, - synchronizeFavorites: true, - synchronizeRating: true, - synchronizeDescription: true, - synchronizeLocation: true, - synchronizeTags: true, -}; - -describe('duplicate-resolve utils', () => { - describe('getSyncedInfo', () => { - it('should return defaults for empty list', () => { - const result = getSyncedInfo([]); - expect(result).toEqual({ - isFavorite: false, - visibility: undefined, - rating: 0, - description: null, - latitude: null, - longitude: null, - tagIds: [], - }); - }); - - describe('isFavorite', () => { - it('should return false if no assets are favorite', () => { - const assets = [createAsset('1', { isFavorite: false }), createAsset('2', { isFavorite: false })]; - expect(getSyncedInfo(assets).isFavorite).toBe(false); - }); - - it('should return true if any asset is favorite', () => { - const assets = [createAsset('1', { isFavorite: false }), createAsset('2', { isFavorite: true })]; - expect(getSyncedInfo(assets).isFavorite).toBe(true); - }); - }); - - describe('visibility', () => { - it('should return undefined if no special visibility', () => { - const assets = [createAsset('1', { visibility: AssetVisibility.Timeline })]; - expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Timeline); - }); - - it('should prioritize Locked over Archive and Timeline', () => { - const assets = [ - createAsset('1', { visibility: AssetVisibility.Timeline }), - createAsset('2', { visibility: AssetVisibility.Archive }), - createAsset('3', { visibility: AssetVisibility.Locked }), - ]; - expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Locked); - }); - - it('should prioritize Archive over Timeline', () => { - const assets = [ - createAsset('1', { visibility: AssetVisibility.Timeline }), - createAsset('2', { visibility: AssetVisibility.Archive }), - ]; - expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Archive); - }); - - it('should use Hidden if no standard visibility but Hidden is present', () => { - const assets = [createAsset('1', { visibility: AssetVisibility.Hidden })]; - expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Hidden); - }); - }); - - describe('rating', () => { - it('should return 0 if no ratings', () => { - const assets = [createAsset('1'), createAsset('2')]; - expect(getSyncedInfo(assets).rating).toBe(0); - }); - - it('should return max rating', () => { - const assets = [ - createAsset('1', { exifInfo: { rating: 3 } }), - createAsset('2', { exifInfo: { rating: 5 } }), - createAsset('3', { exifInfo: { rating: 1 } }), - ]; - expect(getSyncedInfo(assets).rating).toBe(5); - }); - }); - - describe('description', () => { - it('should return null if no descriptions', () => { - const assets = [createAsset('1'), createAsset('2')]; - expect(getSyncedInfo(assets).description).toBeNull(); - }); - - it('should concatenate unique non-empty lines', () => { - const assets = [ - createAsset('1', { exifInfo: { description: 'Line 1\nLine 2' } }), - createAsset('2', { exifInfo: { description: 'Line 2\nLine 3' } }), - ]; - expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2\nLine 3'); - }); - - it('should trim lines and skip empty', () => { - const assets = [createAsset('1', { exifInfo: { description: ' Line 1 \n\n Line 2 \n ' } })]; - expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2'); - }); - }); - - describe('location', () => { - it('should return null if no location data', () => { - const assets = [createAsset('1'), createAsset('2')]; - const result = getSyncedInfo(assets); - expect(result.latitude).toBeNull(); - expect(result.longitude).toBeNull(); - }); - - it('should return coordinates if all assets have same location', () => { - const assets = [ - createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - createAsset('2', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - ]; - const result = getSyncedInfo(assets); - expect(result.latitude).toBe(40.7128); - expect(result.longitude).toBe(-74.006); - }); - - it('should return null if locations differ', () => { - const assets = [ - createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - createAsset('2', { exifInfo: { latitude: 34.0522, longitude: -118.2437 } }), - ]; - const result = getSyncedInfo(assets); - expect(result.latitude).toBeNull(); - expect(result.longitude).toBeNull(); - }); - - it('should ignore assets with missing location', () => { - const assets = [ - createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - createAsset('2', { exifInfo: {} }), - ]; - const result = getSyncedInfo(assets); - expect(result.latitude).toBe(40.7128); - expect(result.longitude).toBe(-74.006); - }); - }); - - describe('tagIds', () => { - it('should return empty array if no tags', () => { - const assets = [createAsset('1'), createAsset('2')]; - expect(getSyncedInfo(assets).tagIds).toEqual([]); - }); - - it('should collect unique tag IDs from all assets', () => { - const assets = [ - createAsset('1', { tags: [{ id: 'tag-1', name: 'Tag 1', value: 'tag-1', createdAt: new Date(), updatedAt: new Date() }] }), - createAsset('2', { tags: [{ id: 'tag-1', name: 'Tag 1', value: 'tag-1', createdAt: new Date(), updatedAt: new Date() }, { id: 'tag-2', name: 'Tag 2', value: 'tag-2', createdAt: new Date(), updatedAt: new Date() }] }), - ]; - const result = getSyncedInfo(assets); - expect(result.tagIds).toHaveLength(2); - expect(result.tagIds).toContain('tag-1'); - expect(result.tagIds).toContain('tag-2'); - }); - }); - }); - - describe('computeResolvePolicy', () => { - it('should always set duplicateId to null in assetBulkUpdate', () => { - const assets = [createAsset('1'), createAsset('2')]; - const policy = computeResolvePolicy(assets, ['1'], allDisabledSettings); - expect(policy.assetBulkUpdate.duplicateId).toBeNull(); - }); - - it('should set ids to idsToKeep', () => { - const assets = [createAsset('1'), createAsset('2')]; - const policy = computeResolvePolicy(assets, ['1', '2'], allDisabledSettings); - expect(policy.assetBulkUpdate.ids).toEqual(['1', '2']); - }); - - it('should not set sync fields when all settings disabled', () => { - const assets = [ - createAsset('1', { - isFavorite: true, - visibility: AssetVisibility.Archive, - exifInfo: { rating: 5, description: 'test' }, - }), - ]; - const policy = computeResolvePolicy(assets, ['1'], allDisabledSettings); - - expect(policy.assetBulkUpdate.isFavorite).toBeUndefined(); - expect(policy.assetBulkUpdate.visibility).toBeUndefined(); - expect(policy.assetBulkUpdate.rating).toBeUndefined(); - expect(policy.assetBulkUpdate.description).toBeUndefined(); - expect(policy.mergedAlbumIds).toEqual([]); - expect(policy.mergedTagIds).toEqual([]); - }); - - it('should set isFavorite when synchronizeFavorites enabled', () => { - const assets = [createAsset('1', { isFavorite: true }), createAsset('2', { isFavorite: false })]; - const settings = { ...allDisabledSettings, synchronizeFavorites: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.isFavorite).toBe(true); - }); - - it('should set visibility when synchronizeVisibility enabled', () => { - const assets = [ - createAsset('1', { visibility: AssetVisibility.Archive }), - createAsset('2', { visibility: AssetVisibility.Timeline }), - ]; - const settings = { ...allDisabledSettings, synchronizeVisibility: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.visibility).toBe(AssetVisibility.Archive); - }); - - it('should set rating when synchronizeRating enabled', () => { - const assets = [createAsset('1', { exifInfo: { rating: 3 } }), createAsset('2', { exifInfo: { rating: 5 } })]; - const settings = { ...allDisabledSettings, synchronizeRating: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.rating).toBe(5); - }); - - it('should set description when synchronizeDescription enabled and non-null', () => { - const assets = [createAsset('1', { exifInfo: { description: 'Test description' } })]; - const settings = { ...allDisabledSettings, synchronizeDescription: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.description).toBe('Test description'); - }); - - it('should not set description when null', () => { - const assets = [createAsset('1')]; - const settings = { ...allDisabledSettings, synchronizeDescription: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.description).toBeUndefined(); - }); - - it('should set location when synchronizeLocation enabled and coordinates match', () => { - const assets = [ - createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - createAsset('2', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - ]; - const settings = { ...allDisabledSettings, synchronizeLocation: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.latitude).toBe(40.7128); - expect(policy.assetBulkUpdate.longitude).toBe(-74.006); - }); - - it('should not set location when coordinates differ', () => { - const assets = [ - createAsset('1', { exifInfo: { latitude: 40.7128, longitude: -74.006 } }), - createAsset('2', { exifInfo: { latitude: 34.0522, longitude: -118.2437 } }), - ]; - const settings = { ...allDisabledSettings, synchronizeLocation: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.assetBulkUpdate.latitude).toBeUndefined(); - expect(policy.assetBulkUpdate.longitude).toBeUndefined(); - }); - - it('should return merged album IDs when synchronizeAlbums enabled', () => { - const assets = [createAsset('1'), createAsset('2')]; - const settings = { ...allDisabledSettings, synchronizeAlbums: true }; - const assetAlbumMap = new Map([ - ['1', ['album-1', 'album-2']], - ['2', ['album-2', 'album-3']], - ]); - const policy = computeResolvePolicy(assets, ['1'], settings, assetAlbumMap); - expect(policy.mergedAlbumIds).toHaveLength(3); - expect(policy.mergedAlbumIds).toContain('album-1'); - expect(policy.mergedAlbumIds).toContain('album-2'); - expect(policy.mergedAlbumIds).toContain('album-3'); - }); - - it('should return merged tag IDs when synchronizeTags enabled', () => { - const assets = [ - createAsset('1', { tags: [{ id: 'tag-1', name: 'Tag 1', value: 'tag-1', createdAt: new Date(), updatedAt: new Date() }] }), - createAsset('2', { tags: [{ id: 'tag-2', name: 'Tag 2', value: 'tag-2', createdAt: new Date(), updatedAt: new Date() }] }), - ]; - const settings = { ...allDisabledSettings, synchronizeTags: true }; - const policy = computeResolvePolicy(assets, ['1'], settings); - expect(policy.mergedTagIds).toHaveLength(2); - expect(policy.mergedTagIds).toContain('tag-1'); - expect(policy.mergedTagIds).toContain('tag-2'); - }); - }); -}); diff --git a/server/src/utils/duplicate-resolve.ts b/server/src/utils/duplicate-resolve.ts deleted file mode 100644 index b3aa0c8f6ef10..0000000000000 --- a/server/src/utils/duplicate-resolve.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { AssetBulkUpdateDto } from 'src/dtos/asset.dto'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { DuplicateResolveSettingsDto } from 'src/dtos/duplicate.dto'; -import { AssetVisibility } from 'src/enum'; - -/** - * Represents the synchronized information computed from a duplicate group. - * This mirrors the client-side getSyncedInfo function behavior. - */ -export interface SyncedInfo { - isFavorite: boolean; - visibility: AssetVisibility | undefined; - rating: number; - description: string | null; - latitude: number | null; - longitude: number | null; - tagIds: string[]; -} - -/** - * Result of computing the resolve policy for a duplicate group. - */ -export interface ResolvePolicy { - /** Bulk update to apply to keeper assets */ - assetBulkUpdate: AssetBulkUpdateDto; - /** Album IDs to add keeper assets to (if synchronizeAlbums enabled) */ - mergedAlbumIds: string[]; - /** Tag IDs to apply to keeper assets (if synchronizeTags enabled) */ - mergedTagIds: string[]; -} - -/** - * Computes synchronized information from a list of assets. - * This mirrors the client-side getSyncedInfo function in the duplicates page. - * - * @param assets List of assets in the duplicate group (full AssetResponseDto with exif, tags, etc.) - * @returns Computed synced info - */ -export const getSyncedInfo = (assets: AssetResponseDto[]): SyncedInfo => { - if (assets.length === 0) { - return { - isFavorite: false, - visibility: undefined, - rating: 0, - description: null, - latitude: null, - longitude: null, - tagIds: [], - }; - } - - // If any of the assets is favorite, we consider the synced info as favorite - const isFavorite = assets.some((asset) => asset.isFavorite); - - // Choose the most restrictive user-visible level (Hidden is internal-only) - const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline]; - let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); - if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) { - visibility = AssetVisibility.Hidden; - } - - // Choose the highest rating from the exif data of the assets - let rating = 0; - for (const asset of assets) { - 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): string[] => { - const unique = new Set(); - 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 descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); - const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; - - // Helper: return unique numeric coordinate or null - const getUniqueCoordinate = (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 Set(values); - return unique.size === 1 ? [...unique][0] : null; - }; - - const latitude = getUniqueCoordinate('latitude'); - const longitude = getUniqueCoordinate('longitude'); - - // Collect all unique tag IDs from all assets - const tagIds = [ - ...new Set( - assets - .flatMap((asset) => asset.tags ?? []) - .map((tag) => tag.id) - .filter((id): id is string => !!id), - ), - ]; - - return { isFavorite, visibility, rating, description, latitude, longitude, tagIds }; -}; - -/** - * Computes the resolve policy for a duplicate group based on settings and assets. - * This is pure domain logic with no database access. - * - * @param assets All assets in the duplicate group - * @param idsToKeep Asset IDs that will be kept (derived from request) - * @param settings Sync settings from the request - * @param assetAlbumMap Map of asset IDs to their album IDs (only needed if synchronizeAlbums is enabled) - * @returns The resolve policy to apply - */ -export const computeResolvePolicy = ( - assets: AssetResponseDto[], - idsToKeep: string[], - settings: DuplicateResolveSettingsDto, - assetAlbumMap: Map = new Map(), -): ResolvePolicy => { - const needsSyncedInfo = - settings.synchronizeFavorites || - settings.synchronizeVisibility || - settings.synchronizeRating || - settings.synchronizeDescription || - settings.synchronizeLocation || - settings.synchronizeTags; - - const syncedInfo = needsSyncedInfo ? getSyncedInfo(assets) : null; - - // Build the asset bulk update for keeper assets - const assetBulkUpdate: AssetBulkUpdateDto = { - ids: idsToKeep, - duplicateId: null, // Always clear the duplicate ID - }; - - if (settings.synchronizeFavorites && syncedInfo) { - assetBulkUpdate.isFavorite = syncedInfo.isFavorite; - } - - if (settings.synchronizeVisibility && syncedInfo) { - assetBulkUpdate.visibility = syncedInfo.visibility; - } - - if (settings.synchronizeRating && syncedInfo) { - assetBulkUpdate.rating = syncedInfo.rating; - } - - if (settings.synchronizeDescription && syncedInfo && syncedInfo.description !== null) { - assetBulkUpdate.description = syncedInfo.description; - } - - // If all assets have the same location, use it; otherwise don't set it (leave as-is) - if (settings.synchronizeLocation && syncedInfo && syncedInfo.latitude !== null && syncedInfo.longitude !== null) { - assetBulkUpdate.latitude = syncedInfo.latitude; - assetBulkUpdate.longitude = syncedInfo.longitude; - } - - // Compute merged album IDs if synchronizeAlbums is enabled - let mergedAlbumIds: string[] = []; - if (settings.synchronizeAlbums) { - const albumIdSet = new Set(); - for (const [, albumIds] of assetAlbumMap) { - for (const albumId of albumIds) { - albumIdSet.add(albumId); - } - } - mergedAlbumIds = [...albumIdSet]; - } - - // Merged tag IDs from synced info if synchronizeTags is enabled - const mergedTagIds = settings.synchronizeTags && syncedInfo ? syncedInfo.tagIds : []; - - return { - assetBulkUpdate, - mergedAlbumIds, - mergedTagIds, - }; -}; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 208b09c120caf..f723113bd1785 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -33,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + duplicate: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + memory: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, 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 63c0dead881ce..639f1067e76c7 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 @@ -13,7 +13,7 @@ import { duplicateSettings, locale } from '$lib/stores/preferences.store'; import { handleError } from '$lib/utils/handle-error'; import type { AssetResponseDto } from '@immich/sdk'; - import { deleteDuplicates, resolveDuplicates, stackDuplicates, Status } from '@immich/sdk'; + import { createStack, deleteDuplicates, resolveDuplicates, updateAssets } from '@immich/sdk'; import { Button, HStack, IconButton, modalManager, Text, toastManager } from '@immich/ui'; import { mdiCheckOutline, @@ -113,9 +113,9 @@ }, }); - const result = response.results[0]; - if (result.status === Status.Failed) { - throw new Error(result.reason ?? 'Failed to resolve duplicate group'); + const { success, error, errorMessage } = response[0]; + if (!success) { + throw new Error(errorMessage || error); } duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); @@ -130,12 +130,8 @@ const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => { const assetIds = assets.map((asset) => asset.id); - await stackDuplicates({ - duplicateStackDto: { - duplicateId, - assetIds, - }, - }); + await createStack({ stackCreateDto: { assetIds } }); + await updateAssets({ assetBulkUpdateDto: { ids: assetIds, duplicateId: null } }); duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); await navigateToIndex(duplicatesIndex); }; @@ -174,7 +170,7 @@ }); // Count failures and show appropriate message - const failedCount = response.results.filter((r) => r.status === Status.Failed).length; + const failedCount = response.filter(({ success }) => !success).length; if (failedCount > 0) { toastManager.danger($t('errors.unable_to_resolve_duplicate')); } From d0501b8e50415991d13690e5ab0bb4952ca71ae3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 2 Feb 2026 14:45:55 -0500 Subject: [PATCH 11/25] fix: preference upgrade --- web/src/lib/stores/preferences.store.ts | 22 +++++++++++++--------- web/src/lib/utils/persisted.ts | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index baa776e22f1cf..c56e0966fc73e 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -152,12 +152,16 @@ export const alwaysLoadOriginalVideo = persisted('always-load-original- export const recentAlbumsDropdown = persisted('recent-albums-open', true, {}); -export const duplicateSettings = new PersistedLocalStorage('duplicate-settings', { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: false, -}); +export const duplicateSettings = new PersistedLocalStorage( + 'duplicate-settings', + { + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: false, + }, + { upgrade: 'merge' }, +); diff --git a/web/src/lib/utils/persisted.ts b/web/src/lib/utils/persisted.ts index 73eb4de5db2af..008352ac8f5dd 100644 --- a/web/src/lib/utils/persisted.ts +++ b/web/src/lib/utils/persisted.ts @@ -46,11 +46,25 @@ type PersistedLocalStorageOptions = { parse(text: string): T; }; valid?: (value: T | unknown) => value is T; + upgrade?: 'merge' | ((value: T) => T); }; +const merge = (defaultValue: T) => { + return (value: T): T => { + if (typeof value === 'object') { + value = { ...defaultValue, ...value } as T; + } + + return value; + }; +}; + +const identity = (value: T): T => value; + export class PersistedLocalStorage extends PersistedBase { constructor(key: string, defaultValue: T, options: PersistedLocalStorageOptions = {}) { const valid = options.valid || (() => true); + const upgrade = options.upgrade === 'merge' ? merge(defaultValue) : identity; const serializer = options.serializer || JSON; super(key, defaultValue, { @@ -69,7 +83,7 @@ export class PersistedLocalStorage extends PersistedBase { return; } - return parsed; + return upgrade(parsed); }, write: (key: string, value: T) => { if (browser) { From b6d4fa603a3734231cc09bc884f6c9854ca58a1f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 2 Feb 2026 14:57:03 -0500 Subject: [PATCH 12/25] chore: linting --- server/src/utils/duplicate.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts index 62ce2399fd3a1..b921be4774767 100644 --- a/server/src/utils/duplicate.spec.ts +++ b/server/src/utils/duplicate.spec.ts @@ -29,9 +29,11 @@ const createAsset = ( isArchived: false, isTrashed: false, isOffline: false, + isEdited: false, visibility: AssetVisibility.Timeline, checksum: 'checksum', - exifInfo: fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? { fileSizeInByte, ...exifFields } : undefined, + exifInfo: + fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? { fileSizeInByte, ...exifFields } : undefined, }); describe('duplicate utils', () => { From 74402ea99f2fa2b3b98b1d7551fa20ff570f9d83 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Tue, 3 Feb 2026 20:48:26 +0100 Subject: [PATCH 13/25] refactor(e2e): use updateAssets API for setAssetDuplicateId --- e2e/src/utils.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 20bf753c82f96..2586615607352 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -499,14 +499,8 @@ 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]); - }, + setAssetDuplicateId: (accessToken: string, assetId: string, duplicateId: string | null) => + updateAssets({ assetBulkUpdateDto: { ids: [assetId], duplicateId } }, { headers: asBearerAuth(accessToken) }), upsertTags: (accessToken: string, tags: string[]) => upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }), From 10e2b2ca5c414531f382e942ecd34fcaa4355f74 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Tue, 3 Feb 2026 21:05:04 +0100 Subject: [PATCH 14/25] fix: visibility sync logic in duplicate resolution --- server/src/services/duplicate.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 6713567ef6190..e3338bd686609 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -252,8 +252,11 @@ export class DuplicateService extends BaseService { if (settings.syncVisibility) { const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline]; - const visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); + let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) { + visibility = AssetVisibility.Hidden; + } + if (visibility) { response.assetUpdate.visibility = visibility; } } From d1c56a9a81c1954a51990fb4c8a8346bc28844be Mon Sep 17 00:00:00 2001 From: Phlogi Date: Tue, 3 Feb 2026 21:07:34 +0100 Subject: [PATCH 15/25] fix(duplicate): write description to exifUpdate Previously the duplicate resolution populated assetUpdate.description even though description belongs to exif info. --- server/src/services/duplicate.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index e3338bd686609..b55566da8d478 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -22,13 +22,13 @@ type ResolveRequest = { assetUpdate: { isFavorite?: boolean; visibility?: AssetVisibility; - description?: string; }; exifUpdate: { rating?: number; latitude?: number; longitude?: number; + description?: string; }; mergedAlbumIds: string[]; @@ -276,7 +276,7 @@ export class DuplicateService extends BaseService { const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; if (description !== null) { - response.assetUpdate.description = description; + response.exifUpdate.description = description; } } From fad63608a4ceafde5efa0d93c77ae52584c9de2b Mon Sep 17 00:00:00 2001 From: Phlogi Date: Tue, 3 Feb 2026 21:21:32 +0100 Subject: [PATCH 16/25] fix(duplicate): remove redundant updateLockedColumns wrapper updateAllExif already computes lockedProperties via distinctLocked using Object.keys(options). The wrapper added a lockedProperties key to the options object, causing the spurious string 'lockedProperties' to be stored in the lockedProperties array. --- server/src/services/duplicate.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index b55566da8d478..b70ead792b015 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -14,7 +14,6 @@ import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; -import { updateLockedColumns } from 'src/utils/database'; import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; @@ -207,7 +206,7 @@ export class DuplicateService extends BaseService { if (idsToKeep.length > 0) { if (Object.keys(exifUpdate).length > 0) { - await this.assetRepository.updateAllExif(idsToKeep, updateLockedColumns(exifUpdate)); + await this.assetRepository.updateAllExif(idsToKeep, exifUpdate); await this.jobRepository.queueAll(idsToKeep.map((id) => ({ name: JobName.SidecarWrite, data: { id } }))); } From 553c6ef5e5dd78e3bf4c93fe5b8ee328303d0756 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Tue, 3 Feb 2026 22:10:55 +0100 Subject: [PATCH 17/25] fix(duplicate): write merged tags to asset_exif to survive metadata re-extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During duplicate resolution, replaceAssetTags correctly wrote merged tag IDs to the tag_asset table, but never updated asset_exif.tags or locked the tags property. The subsequent SidecarWrite → AssetExtractMetadata chain calls applyTagList, which destructively replaces tag_asset rows with whatever is in asset_exif.tags — still the original per-asset tags, not the merged set. Write merged tag values to asset_exif.tags via updateAllExif (which also locks the property via distinctLocked), and queue SidecarWrite when tags change so they persist to the sidecar file. --- server/src/services/duplicate.service.spec.ts | 47 +++++++++++++++++++ server/src/services/duplicate.service.ts | 31 +++++++----- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 45be095294bce..e0ad2b78be9d8 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -325,6 +325,53 @@ describe(DuplicateService.name, () => { expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds'); }); + it('should sync merged tags to asset_exif.tags when syncTags is enabled', async () => { + mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); + mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); + mocks.duplicateRepository.get.mockResolvedValue({ + duplicateId: 'group-1', + assets: [ + { + ...assetStub.image, + id: 'asset-1', + tags: [{ id: 'tag-1', value: 'Work', createdAt: new Date(), updatedAt: new Date(), userId: 'user-1' }], + }, + { + ...assetStub.image, + id: 'asset-2', + tags: [{ id: 'tag-2', value: 'Travel', createdAt: new Date(), updatedAt: new Date(), userId: 'user-1' }], + }, + ], + }); + + const result = await sut.resolve(authStub.admin, { + groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-2'] }], + settings: { + syncAlbums: false, + syncVisibility: false, + syncFavorites: false, + syncRating: false, + syncDescription: false, + syncLocation: false, + syncTags: true, + }, + }); + + expect(result[0].success).toBe(true); + + // Verify tags were applied to tag_asset table + expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith('asset-1', ['tag-1', 'tag-2']); + + // Verify merged tag values were written to asset_exif.tags so SidecarWrite preserves them + expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { tags: ['Work', 'Travel'] }); + + // Verify SidecarWrite was queued (to write tags to sidecar) + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.SidecarWrite, data: { id: 'asset-1' } }, + ]); + }); + // NOTE: The following integration-style tests are covered by E2E tests instead // to avoid complex mock setup. The validation and error-handling logic above // is thoroughly unit tested. diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index b70ead792b015..16575ffc3e87e 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -33,6 +33,8 @@ type ResolveRequest = { mergedAlbumIds: string[]; mergedTagIds: string[]; + + mergedTagValues: string[]; }; const uniqueNonEmptyLines = (values: Array): string[] => { @@ -165,7 +167,7 @@ export class DuplicateService extends BaseService { ? await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]) : new Map(); - const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds } = this.getSyncMergeResult( + const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult( duplicateGroup.assets, settings, assetAlbumMap, @@ -201,12 +203,22 @@ export class DuplicateService extends BaseService { if (allowedTagIds.size > 0) { // Replace tags for each keeper asset to ensure all merged tags are applied await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds]))); + + // Update asset_exif.tags so the subsequent SidecarWrite + MetadataExtraction + // cycle preserves the merged tags (updateAllExif locks the property automatically) + await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues }); } } if (idsToKeep.length > 0) { - if (Object.keys(exifUpdate).length > 0) { + const hasExifUpdate = Object.keys(exifUpdate).length > 0; + const hasTagUpdate = mergedTagIds.length > 0; + + if (hasExifUpdate) { await this.assetRepository.updateAllExif(idsToKeep, exifUpdate); + } + + if (hasExifUpdate || hasTagUpdate) { await this.jobRepository.queueAll(idsToKeep.map((id) => ({ name: JobName.SidecarWrite, data: { id } }))); } @@ -241,6 +253,7 @@ export class DuplicateService extends BaseService { const response: ResolveRequest = { mergedAlbumIds: [], mergedTagIds: [], + mergedTagValues: [], assetUpdate: {}, exifUpdate: {}, }; @@ -299,16 +312,12 @@ export class DuplicateService extends BaseService { } if (settings.syncTags) { - const tagIds = [ - ...new Set( - assets - .flatMap((asset) => asset.tags ?? []) - .map((tag) => tag.id) - .filter((id): id is string => !!id), - ), - ]; + const allTags = assets.flatMap((asset) => asset.tags ?? []); + const tagIds = [...new Set(allTags.map((tag) => tag.id).filter((id): id is string => !!id))]; + const tagValues = [...new Set(allTags.map((tag) => tag.value).filter((v): v is string => !!v))]; if (tagIds.length > 0) { - response.mergedTagIds = settings.syncTags ? tagIds : []; + response.mergedTagIds = tagIds; + response.mergedTagValues = tagValues; } } From 4d8da9e85a64d788950df64b42f5e67abe7f36a2 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Tue, 3 Feb 2026 22:19:04 +0100 Subject: [PATCH 18/25] docs(duplicates): clarify location and tag sync behavior --- docs/docs/features/duplicates-utility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/duplicates-utility.md b/docs/docs/features/duplicates-utility.md index 8a1d9e05de8b7..77718c1744017 100644 --- a/docs/docs/features/duplicates-utility.md +++ b/docs/docs/features/duplicates-utility.md @@ -17,5 +17,5 @@ Additionally, there are synchronization settings that can be used to synchronize | Rating | `false` | When enabled, if one or more assets in the duplicate group have a rating, the highest rating is selected and then synchronized to the kept assets. | | Description | `false` | When enabled, descriptions from each asset are combined together and then synchronized to all the kept assets. | | Visibility | `false` | When enabled, the most restrictive visibility is applied the the kept assets. | -| Location | `false` | When enabled, latitude and longitude are only copied if among the group there is a single asset with geolocation data. | -| Tag | `false` | When enabled, the kept assets will be tagged with _every_ tag that the other assets in the group were tagged with. | +| Location | `false` | When enabled, latitude and longitude are only copied if all assets with geolocation data in the group share the same coordinates. | +| Tag | `false` | When enabled, tags from all assets in the group are merged and applied to every kept asset. | From a5bb16546dc2f9cca6bba45f85100e8043fbc0e5 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Thu, 5 Feb 2026 06:49:32 +0100 Subject: [PATCH 19/25] refactor(duplicate): remove sync settings, always sync all metadata on resolve Remove DuplicateSyncSettingsDto and the per-field sync toggles (albums, favorites, rating, description, visibility, location, tags). Duplicate resolution now unconditionally syncs all metadata from trashed assets to kept assets. - Remove DuplicateSyncSettingsDto and settings field from DuplicateResolveDto - Update DuplicateService to always run all sync logic without conditionals - Delete DuplicateSettingsModal.svelte and settings gear button from UI - Remove DuplicateSettings type and duplicateSettings persisted store - Update unit and e2e tests to remove settings from resolve requests --- e2e/src/api/specs/duplicate.e2e-spec.ts | 7 - open-api/immich-openapi-specs.json | 50 +-- .../src/controllers/duplicate.controller.ts | 2 +- server/src/dtos/duplicate.dto.ts | 59 +-- server/src/services/duplicate.service.spec.ts | 351 +----------------- server/src/services/duplicate.service.ts | 110 ++---- .../lib/modals/DuplicateSettingsModal.svelte | 52 --- web/src/lib/stores/preferences.store.ts | 15 - web/src/lib/types.ts | 9 - .../[[assetId=id]]/+page.svelte | 15 +- 10 files changed, 45 insertions(+), 625 deletions(-) delete mode 100644 web/src/lib/modals/DuplicateSettingsModal.svelte diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts index e275d62452352..d6d0ec1394b02 100644 --- a/e2e/src/api/specs/duplicate.e2e-spec.ts +++ b/e2e/src/api/specs/duplicate.e2e-spec.ts @@ -313,7 +313,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncFavorites: true }, }); expect(status).toBe(200); @@ -343,7 +342,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncVisibility: true }, }); expect(status).toBe(200); @@ -376,7 +374,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncRating: true }, }); expect(status).toBe(200); @@ -409,7 +406,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncDescription: true }, }); expect(status).toBe(200); @@ -442,7 +438,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncLocation: true }, }); expect(status).toBe(200); @@ -480,7 +475,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncAlbums: true }, }); expect(status).toBe(200); @@ -528,7 +522,6 @@ describe('/duplicates', () => { .set('Authorization', `Bearer ${user1.accessToken}`) .send({ groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - settings: { syncTags: true }, }); expect(status).toBe(200); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c0524070f7722..3e09db0ea6ada 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5272,7 +5272,7 @@ }, "/duplicates/resolve": { "post": { - "description": "Resolve duplicate groups by synchronizing metadata across assets and optionally deleting/trashing duplicates.", + "description": "Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.", "operationId": "resolveDuplicates", "parameters": [], "requestBody": { @@ -17768,14 +17768,6 @@ }, "minItems": 1, "type": "array" - }, - "settings": { - "allOf": [ - { - "$ref": "#/components/schemas/DuplicateSyncSettingsDto" - } - ], - "description": "Settings for synchronization behavior" } }, "required": [ @@ -17842,46 +17834,6 @@ ], "type": "object" }, - "DuplicateSyncSettingsDto": { - "properties": { - "syncAlbums": { - "default": false, - "description": "Synchronize album membership across duplicate group", - "type": "boolean" - }, - "syncDescription": { - "default": false, - "description": "Synchronize description across duplicate group", - "type": "boolean" - }, - "syncFavorites": { - "default": false, - "description": "Synchronize favorite status across duplicate group", - "type": "boolean" - }, - "syncLocation": { - "default": false, - "description": "Synchronize GPS location across duplicate group", - "type": "boolean" - }, - "syncRating": { - "default": false, - "description": "Synchronize EXIF rating across duplicate group", - "type": "boolean" - }, - "syncTags": { - "default": false, - "description": "Synchronize tags across duplicate group", - "type": "boolean" - }, - "syncVisibility": { - "default": false, - "description": "Synchronize visibility (archive/timeline) across duplicate group", - "type": "boolean" - } - }, - "type": "object" - }, "EmailNotificationsResponse": { "properties": { "albumInvite": { diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index 32e26d64fc876..f8bdaae9b0ef1 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -55,7 +55,7 @@ export class DuplicateController { @Endpoint({ summary: 'Resolve duplicate groups', description: - 'Resolve duplicate groups by synchronizing metadata across assets and optionally deleting/trashing duplicates.', + 'Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.', history: new HistoryBuilder().added('v2.6.0').alpha('v2.6.0'), }) resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise { diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 01bdde0d2158c..40b1b74c70ab6 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { ValidateUUID } from 'src/validation'; export class DuplicateResponseDto { @ApiProperty({ description: 'Duplicate group ID' }) @@ -14,57 +14,6 @@ export class DuplicateResponseDto { suggestedKeepAssetIds!: string[]; } -export class DuplicateSyncSettingsDto { - @ValidateBoolean({ - description: 'Synchronize album membership across duplicate group', - default: false, - optional: true, - }) - syncAlbums?: boolean; - - @ValidateBoolean({ - description: 'Synchronize visibility (archive/timeline) across duplicate group', - default: false, - optional: true, - }) - syncVisibility?: boolean; - - @ValidateBoolean({ - description: 'Synchronize favorite status across duplicate group', - default: false, - optional: true, - }) - syncFavorites?: boolean; - - @ValidateBoolean({ - description: 'Synchronize EXIF rating across duplicate group', - default: false, - optional: true, - }) - syncRating?: boolean; - - @ValidateBoolean({ - description: 'Synchronize description across duplicate group', - default: false, - optional: true, - }) - syncDescription?: boolean; - - @ValidateBoolean({ - description: 'Synchronize GPS location across duplicate group', - default: false, - optional: true, - }) - syncLocation?: boolean; - - @ValidateBoolean({ - description: 'Synchronize tags across duplicate group', - default: false, - optional: true, - }) - syncTags?: boolean; -} - export class DuplicateResolveGroupDto { @ValidateUUID() duplicateId!: string; @@ -83,10 +32,4 @@ export class DuplicateResolveDto { @Type(() => DuplicateResolveGroupDto) @ArrayMinSize(1) groups!: DuplicateResolveGroupDto[]; - - @ApiProperty({ description: 'Settings for synchronization behavior' }) - @ValidateNested() - @Optional() - @Type(() => DuplicateSyncSettingsDto) - settings?: DuplicateSyncSettingsDto; } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index e0ad2b78be9d8..dc1fcc246b1f6 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -196,15 +196,6 @@ describe(DuplicateService.name, () => { await expect( sut.resolve(authStub.admin, { groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }], - settings: { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: false, - }, }), ).resolves.toEqual([ { @@ -255,15 +246,6 @@ describe(DuplicateService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-1'] }], - settings: { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: false, - }, }); expect(result[0].success).toBe(false); @@ -283,15 +265,6 @@ describe(DuplicateService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-2'] }], - settings: { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: false, - }, }); expect(result[0].success).toBe(false); @@ -310,22 +283,13 @@ describe(DuplicateService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: ['asset-1'] }], - settings: { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: false, - }, }); expect(result[0].success).toBe(false); expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds'); }); - it('should sync merged tags to asset_exif.tags when syncTags is enabled', async () => { + it('should sync merged tags to asset_exif.tags', async () => { mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); @@ -347,15 +311,6 @@ describe(DuplicateService.name, () => { const result = await sut.resolve(authStub.admin, { groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-1'], trashAssetIds: ['asset-2'] }], - settings: { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: true, - }, }); expect(result[0].success).toBe(true); @@ -535,307 +490,3 @@ describe(DuplicateService.name, () => { }); }); -// TODO: fix these tests - -// const allDisabledSettings: DuplicateSyncSettingsDto = { -// syncAlbums: false, -// syncVisibility: false, -// syncFavorites: false, -// syncRating: false, -// syncDescription: false, -// syncLocation: false, -// syncTags: false, -// }; - -// describe('duplicate-resolve utils', () => { -// describe('getSyncedInfo', () => { -// it('should return defaults for empty list', () => { -// const result = getSyncedInfo([]); -// expect(result).toEqual({ -// isFavorite: false, -// visibility: undefined, -// rating: 0, -// description: null, -// latitude: null, -// longitude: null, -// tagIds: [], -// }); -// }); - -// describe('isFavorite', () => { -// it('should return false if no assets are favorite', () => { -// const assets = [factory.asset({ isFavorite: false }), factory.asset({ isFavorite: false })]; -// expect(getSyncedInfo(assets).isFavorite).toBe(false); -// }); - -// it('should return true if any asset is favorite', () => { -// const assets = [factory.asset({ isFavorite: false }), factory.asset({ isFavorite: true })]; -// expect(getSyncedInfo(assets).isFavorite).toBe(true); -// }); -// }); - -// describe('visibility', () => { -// it('should return undefined if no special visibility', () => { -// const assets = [factory.asset({ visibility: AssetVisibility.Timeline })]; -// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Timeline); -// }); - -// it('should prioritize Locked over Archive and Timeline', () => { -// const assets = [ -// factory.asset({ visibility: AssetVisibility.Timeline }), -// factory.asset({ visibility: AssetVisibility.Archive }), -// factory.asset({ visibility: AssetVisibility.Locked }), -// ]; -// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Locked); -// }); - -// it('should prioritize Archive over Timeline', () => { -// const assets = [ -// factory.asset({ visibility: AssetVisibility.Timeline }), -// factory.asset({ visibility: AssetVisibility.Archive }), -// ]; -// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Archive); -// }); - -// it('should use Hidden if no standard visibility but Hidden is present', () => { -// const assets = [factory.asset({ visibility: AssetVisibility.Hidden })]; -// expect(getSyncedInfo(assets).visibility).toBe(AssetVisibility.Hidden); -// }); -// }); - -// describe('rating', () => { -// it('should return 0 if no ratings', () => { -// const assets = [factory.asset(), factory.asset()]; -// expect(getSyncedInfo(assets).rating).toBe(0); -// }); - -// it('should return max rating', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ rating: 3 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ rating: 5 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ rating: 1 }) }, -// ]; -// expect(getSyncedInfo(assets).rating).toBe(5); -// }); -// }); - -// describe('description', () => { -// it('should return null if no descriptions', () => { -// expect(getSyncedInfo([factory.asset(), factory.asset()]).description).toBeNull(); -// }); - -// it('should concatenate unique non-empty lines', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ description: 'Line 1\nLine 2' }) }, -// { ...factory.asset(), exifInfo: factory.exif({ description: 'Line 2\nLine 3' }) }, -// ]; -// expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2\nLine 3'); -// }); - -// it('should trim lines and skip empty', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ description: ' Line 1 \n\n Line 2 \n ' }) }, -// ]; -// expect(getSyncedInfo(assets).description).toBe('Line 1\nLine 2'); -// }); -// }); - -// describe('location', () => { -// it('should return null if no location data', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif() }, -// { ...factory.asset(), exifInfo: factory.exif() }, -// ]; -// const result = getSyncedInfo(assets); -// expect(result.latitude).toBeNull(); -// expect(result.longitude).toBeNull(); -// }); - -// it('should return coordinates if all assets have same location', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// ]; -// const result = getSyncedInfo(assets); -// expect(result.latitude).toBe(40.7128); -// expect(result.longitude).toBe(-74.006); -// }); - -// it('should return null if locations differ', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 34.0522, longitude: -118.2437 }) }, -// ]; -// const result = getSyncedInfo(assets); -// expect(result.latitude).toBeNull(); -// expect(result.longitude).toBeNull(); -// }); - -// it('should ignore assets with missing location', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// { ...factory.asset(), exifInfo: factory.exif() }, -// ]; -// const result = getSyncedInfo(assets); -// expect(result.latitude).toBe(40.7128); -// expect(result.longitude).toBe(-74.006); -// }); -// }); - -// describe('tagIds', () => { -// it('should return empty array if no tags', () => { -// const assets = [ -// { ...factory.asset(), tags: [] }, -// { ...factory.asset(), tags: [] }, -// ]; -// expect(getSyncedInfo(assets).tagIds).toEqual([]); -// }); - -// it('should collect unique tag IDs from all assets', () => { -// const assets = [ -// { -// ...factory.asset(), -// tags: [factory.tag({ id: 'tag-1', value: 'tag-1' })], -// }, -// { -// ...factory.asset(), -// tags: [factory.tag({ id: 'tag-1', value: 'tag-1' }), factory.tag({ id: 'tag-2', value: 'tag-2' })], -// }, -// ]; -// const result = getSyncedInfo(assets); -// expect(result.tagIds).toHaveLength(2); -// expect(result.tagIds).toContain('tag-1'); -// expect(result.tagIds).toContain('tag-2'); -// }); -// }); -// }); - -// describe('computeResolvePolicy', () => { -// it('should always set duplicateId to null in assetBulkUpdate', () => { -// const assets = [factory.asset(), factory.asset()]; -// const policy = getSyncMergeResult(assets, ['1'], allDisabledSettings); -// expect(policy.assetBulkUpdate.duplicateId).toBeNull(); -// }); - -// it('should set ids to idsToKeep', () => { -// const assets = [factory.asset(), factory.asset()]; -// const policy = getSyncMergeResult(assets, ['1', '2'], allDisabledSettings); -// expect(policy.assetBulkUpdate.ids).toEqual(['1', '2']); -// }); - -// it('should not set sync fields when all settings disabled', () => { -// const assets = [ -// { -// ...factory.asset({ -// isFavorite: true, -// visibility: AssetVisibility.Archive, -// }), -// exifInfo: factory.exif({ rating: 5, description: 'test' }), -// }, -// ]; -// const policy = getSyncMergeResult(assets, ['1'], allDisabledSettings); - -// expect(policy.assetBulkUpdate.isFavorite).toBeUndefined(); -// expect(policy.assetBulkUpdate.visibility).toBeUndefined(); -// expect(policy.assetBulkUpdate.rating).toBeUndefined(); -// expect(policy.assetBulkUpdate.description).toBeUndefined(); -// expect(policy.mergedAlbumIds).toEqual([]); -// expect(policy.mergedTagIds).toEqual([]); -// }); - -// it('should set isFavorite when syncFavorites enabled', () => { -// const assets = [{ ...factory.asset({ isFavorite: true }) }, { ...factory.asset({ isFavorite: false }) }]; -// const settings = { ...allDisabledSettings, syncFavorites: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.isFavorite).toBe(true); -// }); - -// it('should set visibility when syncVisibility enabled', () => { -// const assets = [ -// { ...factory.asset({ visibility: AssetVisibility.Archive }) }, -// { ...factory.asset({ visibility: AssetVisibility.Timeline }) }, -// ]; -// const settings = { ...allDisabledSettings, syncVisibility: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.visibility).toBe(AssetVisibility.Archive); -// }); - -// it('should set rating when syncRating enabled', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ rating: 3 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ rating: 5 }) }, -// ]; -// const settings = { ...allDisabledSettings, syncRating: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.rating).toBe(5); -// }); - -// it('should set description when syncDescription enabled and non-null', () => { -// const assets = [{ ...factory.asset(), exifInfo: factory.exif({ description: 'Test description' }) }]; -// const settings = { ...allDisabledSettings, syncDescription: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.description).toBe('Test description'); -// }); - -// it('should not set description when null', () => { -// const assets = [factory.asset()]; -// const settings = { ...allDisabledSettings, syncDescription: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.description).toBeUndefined(); -// }); - -// it('should set location when syncLocation enabled and coordinates match', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// ]; -// const settings = { ...allDisabledSettings, syncLocation: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.latitude).toBe(40.7128); -// expect(policy.assetBulkUpdate.longitude).toBe(-74.006); -// }); - -// it('should not set location when coordinates differ', () => { -// const assets = [ -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 40.7128, longitude: -74.006 }) }, -// { ...factory.asset(), exifInfo: factory.exif({ latitude: 34.0522, longitude: -118.2437 }) }, -// ]; -// const settings = { ...allDisabledSettings, syncLocation: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.assetBulkUpdate.latitude).toBeUndefined(); -// expect(policy.assetBulkUpdate.longitude).toBeUndefined(); -// }); - -// it('should return merged album IDs when syncAlbums enabled', () => { -// const assets = [factory.asset(), factory.asset()]; -// const settings = { ...allDisabledSettings, syncAlbums: true }; -// const assetAlbumMap = new Map([ -// ['1', ['album-1', 'album-2']], -// ['2', ['album-2', 'album-3']], -// ]); -// const policy = getSyncMergeResult(assets, ['1'], settings, assetAlbumMap); -// expect(policy.mergedAlbumIds).toHaveLength(3); -// expect(policy.mergedAlbumIds).toContain('album-1'); -// expect(policy.mergedAlbumIds).toContain('album-2'); -// expect(policy.mergedAlbumIds).toContain('album-3'); -// }); - -// it('should return merged tag IDs when syncTags enabled', () => { -// const assets = [ -// { -// ...factory.asset({}), -// tags: [factory.tag({ id: 'tag-1', value: 'tag-1' })], -// }, -// { -// ...factory.asset({}), -// tags: [factory.tag({ id: 'tag-2', value: 'tag-2' })], -// }, -// ]; -// const settings = { ...allDisabledSettings, syncTags: true }; -// const policy = getSyncMergeResult(assets, ['1'], settings); -// expect(policy.mergedTagIds).toHaveLength(2); -// expect(policy.mergedTagIds).toContain('tag-1'); -// expect(policy.mergedTagIds).toContain('tag-2'); -// }); -// }); -// }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 16575ffc3e87e..870f667a72d9b 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -4,12 +4,7 @@ import { OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { - DuplicateResolveDto, - DuplicateResolveGroupDto, - DuplicateResponseDto, - DuplicateSyncSettingsDto, -} from 'src/dtos/duplicate.dto'; +import { DuplicateResolveDto, DuplicateResolveGroupDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; @@ -103,7 +98,7 @@ export class DuplicateService extends BaseService { for (const group of dto.groups) { try { - results.push(await this.resolveGroup(auth, group, dto.settings || {})); + results.push(await this.resolveGroup(auth, group)); } catch (error: Error | any) { this.logger.error(`Error resolving duplicate group ${group.duplicateId}: ${error}`, error?.stack); results.push({ id: group.duplicateId, success: false, error: BulkIdErrorReason.UNKNOWN }); @@ -113,11 +108,7 @@ export class DuplicateService extends BaseService { return results; } - private async resolveGroup( - auth: AuthDto, - group: DuplicateResolveGroupDto, - settings: DuplicateSyncSettingsDto, - ): Promise { + private async resolveGroup(auth: AuthDto, group: DuplicateResolveGroupDto): Promise { const { duplicateId, keepAssetIds, trashAssetIds } = group; const duplicateGroup = await this.duplicateRepository.get(duplicateId); @@ -163,13 +154,10 @@ export class DuplicateService extends BaseService { } } - const assetAlbumMap = settings.syncAlbums - ? await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]) - : new Map(); + const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]); const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult( duplicateGroup.assets, - settings, assetAlbumMap, ); @@ -245,11 +233,7 @@ export class DuplicateService extends BaseService { return { id: duplicateId, success: true }; } - private getSyncMergeResult( - assets: MapAsset[], - settings: DuplicateSyncSettingsDto, - assetAlbumMap: Map = new Map(), - ): ResolveRequest { + private getSyncMergeResult(assets: MapAsset[], assetAlbumMap: Map = new Map()): ResolveRequest { const response: ResolveRequest = { mergedAlbumIds: [], mergedTagIds: [], @@ -258,68 +242,54 @@ export class DuplicateService extends BaseService { exifUpdate: {}, }; - if (settings.syncFavorites) { - response.assetUpdate.isFavorite = assets.some((asset) => asset.isFavorite); - } + response.assetUpdate.isFavorite = assets.some((asset) => asset.isFavorite); - if (settings.syncVisibility) { - const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline]; - let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); - if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) { - visibility = AssetVisibility.Hidden; - } - if (visibility) { - response.assetUpdate.visibility = visibility; - } + const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline]; + let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level)); + if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) { + visibility = AssetVisibility.Hidden; } - - if (settings.syncRating) { - let rating = 0; - for (const asset of assets) { - const assetRating = asset.exifInfo?.rating ?? 0; - if (assetRating > rating) { - rating = assetRating; - } - } - response.exifUpdate.rating = rating; + if (visibility) { + response.assetUpdate.visibility = visibility; } - if (settings.syncDescription) { - const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); - const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; - if (description !== null) { - response.exifUpdate.description = description; + let rating = 0; + for (const asset of assets) { + const assetRating = asset.exifInfo?.rating ?? 0; + if (assetRating > rating) { + rating = assetRating; } } + response.exifUpdate.rating = rating; - if (settings.syncLocation) { - const latitude = getUniqueCoordinate(assets, 'latitude'); - const longitude = getUniqueCoordinate(assets, 'longitude'); - if (latitude !== null && longitude !== null) { - response.exifUpdate.latitude = latitude; - response.exifUpdate.longitude = longitude; - } + const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); + const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; + if (description !== null) { + response.exifUpdate.description = description; } - if (settings.syncAlbums) { - const albumIdSet = new Set(); - for (const [, albumIds] of assetAlbumMap) { - for (const albumId of albumIds) { - albumIdSet.add(albumId); - } - } - response.mergedAlbumIds = [...albumIdSet]; + const latitude = getUniqueCoordinate(assets, 'latitude'); + const longitude = getUniqueCoordinate(assets, 'longitude'); + if (latitude !== null && longitude !== null) { + response.exifUpdate.latitude = latitude; + response.exifUpdate.longitude = longitude; } - if (settings.syncTags) { - const allTags = assets.flatMap((asset) => asset.tags ?? []); - const tagIds = [...new Set(allTags.map((tag) => tag.id).filter((id): id is string => !!id))]; - const tagValues = [...new Set(allTags.map((tag) => tag.value).filter((v): v is string => !!v))]; - if (tagIds.length > 0) { - response.mergedTagIds = tagIds; - response.mergedTagValues = tagValues; + const albumIdSet = new Set(); + for (const [, albumIds] of assetAlbumMap) { + for (const albumId of albumIds) { + albumIdSet.add(albumId); } } + response.mergedAlbumIds = [...albumIdSet]; + + const allTags = assets.flatMap((asset) => asset.tags ?? []); + const tagIds = [...new Set(allTags.map((tag) => tag.id).filter((id): id is string => !!id))]; + const tagValues = [...new Set(allTags.map((tag) => tag.value).filter((v): v is string => !!v))]; + if (tagIds.length > 0) { + response.mergedTagIds = tagIds; + response.mergedTagValues = tagValues; + } return response; } diff --git a/web/src/lib/modals/DuplicateSettingsModal.svelte b/web/src/lib/modals/DuplicateSettingsModal.svelte deleted file mode 100644 index be8b56cf6222c..0000000000000 --- a/web/src/lib/modals/DuplicateSettingsModal.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - - {$t('duplicates_synchronize_settings')} - - {$t('duplicates_synchronize_setting_description')} - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index c56e0966fc73e..0019fb48fb0ce 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -1,8 +1,6 @@ import { browser } from '$app/environment'; import { Theme, defaultLang } from '$lib/constants'; -import type { DuplicateSettings } from '$lib/types'; import { getPreferredLocale } from '$lib/utils/i18n'; -import { PersistedLocalStorage } from '$lib/utils/persisted'; import { persisted } from 'svelte-persisted-store'; export interface ThemeSetting { @@ -152,16 +150,3 @@ export const alwaysLoadOriginalVideo = persisted('always-load-original- export const recentAlbumsDropdown = persisted('recent-albums-open', true, {}); -export const duplicateSettings = new PersistedLocalStorage( - 'duplicate-settings', - { - syncAlbums: false, - syncVisibility: false, - syncFavorites: false, - syncRating: false, - syncDescription: false, - syncLocation: false, - syncTags: false, - }, - { upgrade: 'merge' }, -); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 890f2eeaf384f..edaf016466320 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -41,12 +41,3 @@ export enum OnboardingRole { USER = 'user', } -export type DuplicateSettings = { - syncAlbums: boolean; - syncVisibility: boolean; - syncFavorites: boolean; - syncRating: boolean; - syncDescription: boolean; - syncLocation: boolean; - syncTags: boolean; -}; 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 639f1067e76c7..808ffbaf3b757 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 @@ -5,12 +5,11 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; - import DuplicateSettingsModal from '$lib/modals/DuplicateSettingsModal.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 { duplicateSettings, locale } from '$lib/stores/preferences.store'; + import { locale } from '$lib/stores/preferences.store'; import { handleError } from '$lib/utils/handle-error'; import type { AssetResponseDto } from '@immich/sdk'; import { createStack, deleteDuplicates, resolveDuplicates, updateAssets } from '@immich/sdk'; @@ -19,7 +18,6 @@ mdiCheckOutline, mdiChevronLeft, mdiChevronRight, - mdiCogOutline, mdiInformationOutline, mdiKeyboard, mdiPageFirst, @@ -109,7 +107,6 @@ const response = await resolveDuplicates({ duplicateResolveDto: { groups: [{ duplicateId, keepAssetIds, trashAssetIds: trashIds }], - settings: duplicateSettings.current, }, }); @@ -165,7 +162,6 @@ trashAssetIds: group.assets.map((asset) => asset.id).filter((id) => !keepIds.has(id)), }; }), - settings: duplicateSettings.current, }, }); @@ -264,15 +260,6 @@ onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })} aria-label={$t('show_keyboard_shortcuts')} /> - modalManager.show(DuplicateSettingsModal)} - aria-label={$t('settings')} - /> {/snippet} From a373b2099a78a33e7314a1e30435dde1abe0acc6 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Fri, 6 Feb 2026 14:52:34 +0100 Subject: [PATCH 20/25] docs: update duplicates utility to reflect automatic metadata sync --- docs/docs/features/duplicates-utility.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/docs/features/duplicates-utility.md b/docs/docs/features/duplicates-utility.md index 77718c1744017..327af075c492e 100644 --- a/docs/docs/features/duplicates-utility.md +++ b/docs/docs/features/duplicates-utility.md @@ -8,14 +8,14 @@ The review duplicates page allows the user to individually select which assets s ### Synchronizing metadata -Additionally, there are synchronization settings that can be used to synchronize metadata between assets in the group. See the table below for more information about what metadata is available to synchronize. +When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized: -| Name | Default | Description | -| ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| Album | `false` | When enabled, the kept assets will be added to _every_ album that the other assets in the group belong to. | -| Favorite | `false` | When enabled, if any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | -| Rating | `false` | When enabled, if one or more assets in the duplicate group have a rating, the highest rating is selected and then synchronized to the kept assets. | -| Description | `false` | When enabled, descriptions from each asset are combined together and then synchronized to all the kept assets. | -| Visibility | `false` | When enabled, the most restrictive visibility is applied the the kept assets. | -| Location | `false` | When enabled, latitude and longitude are only copied if all assets with geolocation data in the group share the same coordinates. | -| Tag | `false` | When enabled, tags from all assets in the group are merged and applied to every kept asset. | +| Name | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. | +| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | +| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. | +| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. | +| Visibility | The most restrictive visibility is applied to the kept assets. | +| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. | +| Tag | Tags from all assets in the group are merged and applied to every kept asset. | From f899c7ad30a9d55c88eb02d64b3a839a68bf1d34 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Sat, 14 Feb 2026 11:26:44 +0100 Subject: [PATCH 21/25] docs(web): replace duplicates info modal with link to documentation --- docs/docs/features/duplicates-utility.md | 7 ++++++ i18n/en.json | 6 +---- .../modals/DuplicatesInformationModal.svelte | 22 ------------------- .../[[assetId=id]]/+page.svelte | 18 +++------------ 4 files changed, 11 insertions(+), 42 deletions(-) delete mode 100644 web/src/lib/modals/DuplicatesInformationModal.svelte diff --git a/docs/docs/features/duplicates-utility.md b/docs/docs/features/duplicates-utility.md index 327af075c492e..4742dd4dd0a44 100644 --- a/docs/docs/features/duplicates-utility.md +++ b/docs/docs/features/duplicates-utility.md @@ -6,6 +6,13 @@ Immich comes with a duplicates utility to help you detect assets that look visua The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack. +### Automatic preselection + +When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on: + +1. **Image size in bytes** — larger files are preferred as they typically have higher quality. +2. **Count of EXIF data** — assets with more metadata are preferred. + ### Synchronizing metadata When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized: diff --git a/i18n/en.json b/i18n/en.json index 52b643d061d1d..997f586bf864b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -891,10 +891,6 @@ "day": "Day", "days": "Days", "deduplicate_all": "Deduplicate All", - "deduplication_criteria_1": "Image size in bytes", - "deduplication_criteria_2": "Count of EXIF data", - "deduplication_info": "Deduplication Info", - "deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:", "default_locale": "Default Locale", "default_locale_description": "Format dates and numbers based on your browser locale", "delete": "Delete", @@ -972,7 +968,7 @@ "downloading_media": "Downloading media", "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", - "duplicates_description": "Resolve each group by indicating which, if any, are duplicates", + "duplicates_description": "Resolve each group by indicating which, if any, are duplicates.", "duplicates_synchronize_setting_description": "Configure which asset metadata is copied to assets that are kept.", "duplicates_synchronize_settings": "Synchronize", "duration": "Duration", diff --git a/web/src/lib/modals/DuplicatesInformationModal.svelte b/web/src/lib/modals/DuplicatesInformationModal.svelte deleted file mode 100644 index b32165a1aee2b..0000000000000 --- a/web/src/lib/modals/DuplicatesInformationModal.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - -
-

{$t('deduplication_info_description')}

-
    -
  1. {$t('deduplication_criteria_1')}
  2. -
  3. {$t('deduplication_criteria_2')}
  4. -
-
-
-
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 808ffbaf3b757..b8b777742b955 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 @@ -5,7 +5,7 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; - import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; + import LinkToDocs from '$lib/components/LinkToDocs.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { Route } from '$lib/route'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -18,7 +18,6 @@ mdiCheckOutline, mdiChevronLeft, mdiChevronRight, - mdiInformationOutline, mdiKeyboard, mdiPageFirst, mdiPageLast, @@ -265,19 +264,8 @@
{#if duplicates && duplicates.length > 0} -
-
-

{$t('duplicates_description')}

-
- modalManager.show(DuplicatesInformationModal)} - /> +
+

{$t('duplicates_description')}

{#key duplicates[duplicatesIndex].duplicateId} From 00cbfebf929c61dd2180bbcebc387b4952704dee Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 20 Mar 2026 17:16:12 -0400 Subject: [PATCH 22/25] chore: clean up --- i18n/en.json | 2 - open-api/immich-openapi-specs.json | 4 +- .../src/controllers/duplicate.controller.ts | 5 +-- .../src/repositories/duplicate.repository.ts | 2 +- server/src/services/duplicate.service.spec.ts | 40 ++++++++++++++----- server/src/utils/duplicate.spec.ts | 10 ++--- .../duplicates-compare-control.svelte | 2 - web/src/lib/stores/preferences.store.ts | 1 - .../[[assetId=id]]/+page.svelte | 8 ++-- 9 files changed, 45 insertions(+), 29 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index a1b5f3e0d6c9d..587b322c74127 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -969,8 +969,6 @@ "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", "duplicates_description": "Resolve each group by indicating which, if any, are duplicates.", - "duplicates_synchronize_setting_description": "Configure which asset metadata is copied to assets that are kept.", - "duplicates_synchronize_settings": "Synchronize", "duration": "Duration", "edit": "Edit", "edit_album": "Edit album", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bbd7c2143b5c7..598ebde998715 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5332,11 +5332,11 @@ ], "x-immich-history": [ { - "version": "v2.6.0", + "version": "v3.0.0", "state": "Added" }, { - "version": "v2.6.0", + "version": "v3.0.0", "state": "Alpha" } ], diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index f8bdaae9b0ef1..0a8c451ed4229 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -54,9 +54,8 @@ export class DuplicateController { @Authenticated({ permission: Permission.DuplicateDelete }) @Endpoint({ summary: 'Resolve duplicate groups', - description: - 'Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.', - history: new HistoryBuilder().added('v2.6.0').alpha('v2.6.0'), + description: 'Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.', + history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'), }) resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise { return this.service.resolve(auth, dto); diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index 2be72ea9f94b9..d80952cb14d32 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -146,7 +146,7 @@ export class DuplicateRepository { .executeTakeFirst(); if (!result || !result.duplicateId) { - return undefined; + return; } return { duplicateId: result.duplicateId, assets: result.assets }; diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index fe479f186a914..564cffa0bc5fe 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,4 +1,5 @@ import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; import { AssetFactory } from 'test/factories/asset.factory'; @@ -155,7 +156,7 @@ describe(DuplicateService.name, () => { mocks.duplicateRepository.get.mockResolvedValueOnce(void 0); mocks.duplicateRepository.get.mockResolvedValueOnce({ duplicateId: 'group-2', - assets: [asset], + assets: [asset as unknown as MapAsset], }); await expect( @@ -206,7 +207,7 @@ describe(DuplicateService.name, () => { mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', - assets: [asset], + assets: [asset as unknown as MapAsset], }); await expect( @@ -219,7 +220,10 @@ describe(DuplicateService.name, () => { it('should skip when trashAssetIds contains non-member', async () => { const asset = AssetFactory.create(); mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); - mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', assets: [asset] }); + mocks.duplicateRepository.get.mockResolvedValue({ + duplicateId: 'group-1', + assets: [asset as unknown as MapAsset], + }); await expect( sut.resolve(authStub.admin, { @@ -234,7 +238,7 @@ describe(DuplicateService.name, () => { mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', - assets: [asset1, asset2], + assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset], }); const result = await sut.resolve(authStub.admin, { @@ -252,7 +256,7 @@ describe(DuplicateService.name, () => { mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', - assets: [asset1, asset2, asset3], + assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset, asset3 as unknown as MapAsset], }); const result = await sut.resolve(authStub.admin, { @@ -269,7 +273,7 @@ describe(DuplicateService.name, () => { mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1'])); mocks.duplicateRepository.get.mockResolvedValue({ duplicateId: 'group-1', - assets: [asset1, asset2], + assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset], }); const result = await sut.resolve(authStub.admin, { @@ -291,13 +295,31 @@ describe(DuplicateService.name, () => { assets: [ { ...asset1, - tags: [{ id: 'tag-1', value: 'Work', createdAt: new Date(), updatedAt: new Date(), userId: 'user-1' }], + tags: [ + { + id: 'tag-1', + value: 'Work', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + parentId: null, + color: null, + }, + ], }, { ...asset2, - tags: [{ id: 'tag-2', value: 'Travel', createdAt: new Date(), updatedAt: new Date(), userId: 'user-1' }], + tags: [ + { + id: 'tag-2', + value: 'Travel', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + parentId: null, + color: null, + }, + ], }, - ], + ] as any, }); const result = await sut.resolve(authStub.admin, { diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts index b921be4774767..4c5d5ddfc43ce 100644 --- a/server/src/utils/duplicate.spec.ts +++ b/server/src/utils/duplicate.spec.ts @@ -11,20 +11,20 @@ const createAsset = ( id, type: AssetType.Image, thumbhash: null, - localDateTime: new Date(), + localDateTime: new Date().toISOString(), duration: '0:00:00.00000', hasMetadata: true, width: 1920, height: 1080, - createdAt: new Date(), + createdAt: new Date().toISOString(), deviceAssetId: 'device-asset-1', deviceId: 'device-1', ownerId: 'owner-1', originalPath: '/path/to/asset', originalFileName: 'asset.jpg', - fileCreatedAt: new Date(), - fileModifiedAt: new Date(), - updatedAt: new Date(), + fileCreatedAt: new Date().toISOString(), + fileModifiedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), isFavorite: false, isArchived: false, isTrashed: false, diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2d3db788081b5..da8ea66d5d957 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -29,7 +29,6 @@ let trashCount = $derived(assets.length - selectedAssetIds.size); onMount(() => { - // Use server-provided suggested keep asset IDs if (suggestedKeepAssetIds.length > 0) { for (const id of suggestedKeepAssetIds) { selectedAssetIds.add(id); @@ -37,7 +36,6 @@ return; } - // Fallback to first asset if no suggestion if (assets.length > 0) { selectedAssetIds.add(assets[0].id); } diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 7f6ba98a89050..0873337e1feae 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -149,4 +149,3 @@ 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, {}); - 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 f4d447518e9a0..c10109b44a9bc 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,9 +3,9 @@ import { page } from '$app/state'; import { shortcuts } from '$lib/actions/shortcut'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import LinkToDocs from '$lib/components/LinkToDocs.svelte'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; - import LinkToDocs from '$lib/components/LinkToDocs.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import { Route } from '$lib/route'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -262,11 +262,11 @@ {/snippet} -
+
{#if duplicates && duplicates.length > 0} -
+

{$t('duplicates_description')}

-
+ {#key duplicates[duplicatesIndex].duplicateId} Date: Sat, 21 Mar 2026 21:07:01 +0100 Subject: [PATCH 23/25] fix: add missing type cast to jsonAgg in duplicate repository getAll --- server/src/repositories/duplicate.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index d80952cb14d32..199a0339d7b5a 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -70,7 +70,7 @@ export class DuplicateRepository { (join) => join.onTrue(), ) .select('asset.duplicateId') - .select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets')) + .select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets')) .where('asset.ownerId', '=', asUuid(userId)) .where('asset.duplicateId', 'is not', null) .$narrowType<{ duplicateId: NotNull }>() From 219afc178396957fc9738bc657ec1be300bf1af0 Mon Sep 17 00:00:00 2001 From: Phlogi Date: Sat, 21 Mar 2026 21:08:54 +0100 Subject: [PATCH 24/25] fix: skip persisting rating=0 in duplicate merge to avoid unnecessary sidecar write --- server/src/services/duplicate.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 870f667a72d9b..39123e031c2a6 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -260,7 +260,9 @@ export class DuplicateService extends BaseService { rating = assetRating; } } - response.exifUpdate.rating = rating; + if (rating > 0) { + response.exifUpdate.rating = rating; + } const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description)); const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null; From 76071614b6296600b5aa85ef6e745d635be6e9ff Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 26 Mar 2026 14:16:52 -0400 Subject: [PATCH 25/25] chore: linting --- docs/docs/features/duplicates-utility.md | 18 +++++++++--------- .../src/repositories/duplicate.repository.ts | 4 +++- server/test/mappers.ts | 10 ++++++---- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/docs/features/duplicates-utility.md b/docs/docs/features/duplicates-utility.md index 4742dd4dd0a44..f790c427083e5 100644 --- a/docs/docs/features/duplicates-utility.md +++ b/docs/docs/features/duplicates-utility.md @@ -17,12 +17,12 @@ When using "Deduplicate All" or viewing suggestions, Immich automatically presel When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized: -| Name | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. | -| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | -| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. | -| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. | -| Visibility | The most restrictive visibility is applied to the kept assets. | -| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. | -| Tag | Tags from all assets in the group are merged and applied to every kept asset. | +| Name | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. | +| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. | +| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. | +| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. | +| Visibility | The most restrictive visibility is applied to the kept assets. | +| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. | +| Tag | Tags from all assets in the group are merged and applied to every kept asset. | diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index 199a0339d7b5a..6a9b4e9082148 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -70,7 +70,9 @@ export class DuplicateRepository { (join) => join.onTrue(), ) .select('asset.duplicateId') - .select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets')) + .select((eb) => + eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets'), + ) .where('asset.ownerId', '=', asUuid(userId)) .where('asset.duplicateId', 'is not', null) .$narrowType<{ duplicateId: NotNull }>() diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 2f3b24857613f..35ce09902777c 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,4 +1,5 @@ import { Selectable, ShallowDehydrateObject } from 'kysely'; +import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { ActivityTable } from 'src/schema/tables/activity.table'; import { AssetTable } from 'src/schema/tables/asset.table'; @@ -204,10 +205,11 @@ export const getForStack = (stack: ReturnType) => ({ })), }); -export const getForDuplicate = (asset: ReturnType) => ({ - ...getDehydrated(asset), - exifInfo: getDehydrated(asset.exifInfo), -}); +export const getForDuplicate = (asset: ReturnType) => + ({ + ...getDehydrated(asset), + exifInfo: getDehydrated(asset.exifInfo), + }) as unknown as MapAsset; export const getForSharedLink = (sharedLink: ReturnType) => ({ ...sharedLink,