From 5f0c9a5996390f67058f9b656de5a4f864cd540b Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:36:05 +0200 Subject: [PATCH] fix(server): emit sidecarwrites and assetupdates consequently (#31777) * fix(server): emit sidecarwrites and assetupdates consequently * fix medium test --- server/src/services/asset.service.spec.ts | 27 +++++++++++++++++-- server/src/services/asset.service.ts | 21 ++++++++++++--- server/test/medium.factory.ts | 5 ++++ .../workflow/workflow-core-plugin.spec.ts | 4 ++- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 4e40a09350..edbad1223c 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -184,6 +184,11 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, asset.id, { isFavorite: true }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, isFavorite: true }); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith( + 'on_asset_update', + authStub.admin.user.id, + expect.objectContaining({ id: asset.id }), + ); }); it('should update the exif description', async () => { @@ -220,6 +225,8 @@ describe(AssetService.name, () => { lockedPropertiesBehavior: 'append', }), ); + + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarWrite, data: { id: asset.id } }); }); it('should fail linking a live video if the motion part could not be found', async () => { @@ -384,6 +391,22 @@ describe(AssetService.name, () => { }); }); + it('should emit a websocket event if a sidecar write is not necessary', async () => { + const auth = AuthFactory.create(); + const asset1 = AssetFactory.from().owner(auth.user).build(); + const asset2 = AssetFactory.from().owner(auth.user).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + + await sut.updateAll(auth, { ids: [asset1.id, asset2.id], visibility: AssetVisibility.Archive }); + + expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset1.id, asset2.id], { + visibility: AssetVisibility.Archive, + }); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(2); + }); + it('should not update Assets table if no relevant fields are provided', async () => { const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); @@ -736,7 +759,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.upsertMetadata(authStub.admin, asset.id, { items })).rejects.toThrowError( + await expect(sut.upsertMetadata(authStub.admin, asset.id, { items })).rejects.toThrow( 'Duplicate items are not allowed:', ); @@ -754,7 +777,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.upsertBulkMetadata(authStub.admin, { items })).rejects.toThrowError( + await expect(sut.upsertBulkMetadata(authStub.admin, { items })).rejects.toThrow( 'Duplicate items are not allowed:', ); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 073907d264..87787ca2b3 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -113,7 +113,7 @@ export class AssetService extends BaseService { } } - await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating }); + const wroteMetadata = await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating }); const asset = await this.assetRepository.update({ id, ...rest }); @@ -129,6 +129,10 @@ export class AssetService extends BaseService { throw new BadRequestException('Asset not found'); } + if (!wroteMetadata) { + this.websocketRepository.clientSend('on_asset_update', auth.user.id, mapAsset(asset, { auth })); + } + return this.get(auth, id) as Promise; } @@ -160,8 +164,10 @@ export class AssetService extends BaseService { isUndefined, ); + let shouldWriteSidecar = false; if (Object.keys(exifDto).length > 0) { await this.assetRepository.updateAllExif(ids, exifDto); + shouldWriteSidecar = true; } const extractedTimeZone = extractTimeZone(dateTimeOriginal); @@ -172,6 +178,7 @@ export class AssetService extends BaseService { extractedTimeZone?.type === 'fixed' ) { await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name); + shouldWriteSidecar = true; } if (Object.keys(assetDto).length > 0) { @@ -182,7 +189,14 @@ export class AssetService extends BaseService { await this.albumRepository.removeAssetsFromAll(ids); } - await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.SidecarWrite, data: { id } }))); + if (shouldWriteSidecar) { + await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.SidecarWrite, data: { id } }))); + } else { + const assets = await this.assetRepository.getByIds(ids); + for (const asset of assets) { + this.websocketRepository.clientSend('on_asset_update', auth.user.id, mapAsset(asset, { auth })); + } + } } async copy( @@ -508,7 +522,7 @@ export class AssetService extends BaseService { ); if (Object.keys(writes).length === 0) { - return; + return false; } await this.assetRepository.upsertExif({ @@ -519,6 +533,7 @@ export class AssetService extends BaseService { lockedPropertiesBehavior: 'append', }); await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } }); + return true; } async getAssetEdits(auth: AuthDto, id: string): Promise { diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 6913a41fbe..b30e80e9ec 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -64,6 +64,7 @@ import { TagRepository } from 'src/repositories/tag.repository.js'; import { TelemetryRepository } from 'src/repositories/telemetry.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js'; +import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; import { WorkflowRepository } from 'src/repositories/workflow.repository.js'; import { DB } from 'src/schema/index.js'; import { AlbumTable } from 'src/schema/tables/album.table.js'; @@ -616,6 +617,10 @@ const newMockRepository = (key: ClassConstructor) => { return automock(StorageRepository, { args: [{ setContext: () => {} }] }); } + case WebsocketRepository: { + return automock(WebsocketRepository, { args: [undefined, { setContext: () => {} }] }); + } + default: { throw new Error(`Invalid repository key: ${key}`); } diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index 0d4492eba9..6ae7a8ba75 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -14,6 +14,7 @@ import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { PluginRepository } from 'src/repositories/plugin.repository.js'; import { StorageRepository } from 'src/repositories/storage.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; +import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; import { WorkflowRepository } from 'src/repositories/workflow.repository.js'; import { DB } from 'src/schema/index.js'; import { WorkflowExecutionService } from 'src/services/workflow-execution.service.js'; @@ -40,7 +41,7 @@ class WorkflowTestContext extends MediumTestContext