feat(server): resolve duplicates (#25316)

* 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.

* Replace addAssetToAlbums with copyAsset

* fix linter

* feat(web): add duplicate sync fields and fix typo

* 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.

* 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.

* feat(preferences): enable all duplicate sync settings by default

* chore: clean up

* chore: clean up

* refactor: rename & clean up

* fix: preference upgrade

* chore: linting

* refactor(e2e): use updateAssets API for setAssetDuplicateId

* fix: visibility sync logic in duplicate resolution

* fix(duplicate): write description to exifUpdate

Previously the duplicate resolution populated assetUpdate.description even
though description belongs to exif info.

* 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.

* fix(duplicate): write merged tags to asset_exif to survive metadata re-extraction

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.

* docs(duplicates): clarify location and tag sync behavior

* 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

* docs: update duplicates utility to reflect automatic metadata sync

* docs(web): replace duplicates info modal with link to documentation

* chore: clean up

* fix: add missing type cast to jsonAgg in duplicate repository getAll

* fix: skip persisting rating=0 in duplicate merge to avoid unnecessary sidecar write

---------

Co-authored-by: Toni <51962051+EinToni@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Phlogi
2026-03-26 19:33:55 +01:00
committed by GitHub
parent 48fdd39d30
commit 8c6adf7157
42 changed files with 2385 additions and 209 deletions
+25 -1
View File
@@ -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<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, duplicateIds: Set<string>) {
if (duplicateIds.size === 0) {
return new Set<string>();
}
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<DB>) {}
@@ -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);
+44 -1
View File
@@ -125,6 +125,44 @@ export class AlbumRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 })
async getByAssetIds(ownerId: string, assetIds: string[]): Promise<Map<string, string[]>> {
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<string, string[]>();
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<AlbumAssetCount[]> {
@@ -339,7 +377,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();
}
/**
+96 -20
View File
@@ -1,13 +1,19 @@
import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, Selectable, ShallowDehydrateObject, 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';
import { probes } from 'src/repositories/database.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
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,20 +40,39 @@ 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').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
eb.fn
.toJson('asset_exif')
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
.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').as('assets'))
.select((eb) =>
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
)
.where('asset.ownerId', '=', asUuid(userId))
.where('asset.duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>()
@@ -55,29 +80,80 @@ 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<void> {
// 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 get(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<MapAsset[]>().as('assets'))
.where('asset.duplicateId', '=', asUuid(duplicateId))
.where('asset.deletedAt', 'is', null)
.where('asset.stackId', 'is', null)
.groupBy('asset.duplicateId')
.executeTakeFirst();
if (!result || !result.duplicateId) {
return;
}
return { duplicateId: result.duplicateId, assets: result.assets };
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async delete(userId: string, id: string): Promise<void> {
await this.db
@@ -134,7 +210,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()