From aa3529a1b467014b643409793f1ab8cffcf4392d Mon Sep 17 00:00:00 2001 From: Saurav Sharma Date: Fri, 13 Mar 2026 16:25:19 +0530 Subject: [PATCH] fix(server): allow clearing asset description to blank When a user clears a description, exiftool treats empty strings as "delete field" and removes the tags from the sidecar. Then unlockProperties removes the lock, allowing the next metadata extraction to overwrite the empty DB value with the EXIF-embedded description. Fix: skip writing empty description to sidecar (since exiftool would just delete it) and keep 'description' in lockedProperties so subsequent extractions preserve the user's intent of an empty description. Fixes #19168 --- server/src/services/metadata.service.ts | 32 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index d2467ae6d9ecf..da3caa90375bb 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import { constants } from 'node:fs/promises'; import { join, parse } from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { Asset, AssetFile } from 'src/database'; +import { Asset, AssetFile, LockableProperty } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetFileType, @@ -459,10 +459,17 @@ export class MetadataService extends BaseService { lockedProperties, ); + // exiftool treats empty strings as "delete field", which would cause the EXIF-embedded + // description to reappear on re-extraction. When the user has explicitly cleared the + // description, skip writing it to the sidecar and keep 'description' locked so that + // metadata extraction preserves the empty value from the database. + const descriptionCleared = description !== undefined && description === ''; + const sidecarDescription = descriptionCleared ? undefined : description; + const exif = _.omitBy( { - Description: description, - ImageDescription: description, + Description: sidecarDescription, + ImageDescription: sidecarDescription, DateTimeOriginal: mergeTimeZone(dateTimeOriginal, timeZone)?.toISO(), GPSLatitude: latitude, GPSLongitude: longitude, @@ -472,17 +479,26 @@ export class MetadataService extends BaseService { _.isUndefined, ); - if (Object.keys(exif).length === 0) { + // Properties that should remain locked because they cannot be represented in the sidecar + // (e.g. an empty description would be treated as "delete" by exiftool). + const keepLocked: LockableProperty[] = descriptionCleared ? ['description'] : []; + const propertiesToUnlock = lockedProperties.filter((p) => !keepLocked.includes(p)); + + if (Object.keys(exif).length === 0 && keepLocked.length === 0) { return JobStatus.Skipped; } - await this.metadataRepository.writeTags(sidecarPath, exif); + if (Object.keys(exif).length > 0) { + await this.metadataRepository.writeTags(sidecarPath, exif); - if (asset.files.length === 0) { - await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath }); + if (asset.files.length === 0) { + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath }); + } } - await this.assetRepository.unlockProperties(asset.id, lockedProperties); + if (propertiesToUnlock.length > 0) { + await this.assetRepository.unlockProperties(asset.id, propertiesToUnlock); + } return JobStatus.Success; }