From 9fcaacb958068e7ca3c2a7c72606fba78aeacf2a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 27 May 2026 10:00:55 -0400 Subject: [PATCH] refactor: asset create event --- server/src/repositories/event.repository.ts | 4 +- .../src/services/asset-media.service.spec.ts | 1 - server/src/services/asset-media.service.ts | 146 ++++++++---------- server/src/services/user.service.ts | 8 +- .../services/asset-media.service.spec.ts | 7 +- 5 files changed, 79 insertions(+), 87 deletions(-) diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 713828cd95..fa92a6b0b7 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -9,7 +9,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { JobItem, JobSource } from 'src/types'; +import { JobItem, JobSource, UploadFile } from 'src/types'; type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @@ -42,7 +42,7 @@ type EventMap = { AlbumInvite: [{ id: string; userId: string; senderName: string }]; // asset events - AssetCreate: [{ asset: Asset }]; + AssetCreate: [{ asset: Asset; file: UploadFile }]; AssetTag: [{ assetId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index cb3cc4d62a..9ef72a0897 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -339,7 +339,6 @@ describe(AssetMediaService.name, () => { }); expect(mocks.asset.create).toHaveBeenCalled(); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); expect(mocks.storage.utimes).toHaveBeenCalledWith( file.originalPath, expect.any(Date), diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 6b0d73b77b..de8c2bbc91 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -146,17 +146,79 @@ export class AssetMediaService extends BaseService { { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, ); } - const asset = await this.create(auth.user.id, dto, file, sidecarFile); + + const asset = await this.assetRepository.create({ + ownerId: auth.user.id, + libraryId: null, + + checksum: file.checksum, + checksumAlgorithm: ChecksumAlgorithm.sha1File, + originalPath: file.originalPath, + + fileCreatedAt: dto.fileCreatedAt, + fileModifiedAt: dto.fileModifiedAt, + localDateTime: dto.fileCreatedAt, + + type: mimeTypes.assetType(file.originalPath), + isFavorite: dto.isFavorite, + duration: dto.duration || null, + visibility: dto.visibility ?? AssetVisibility.Timeline, + livePhotoVideoId: dto.livePhotoVideoId, + originalFileName: dto.filename || file.originalName, + }); + + if (dto.metadata?.length) { + await this.assetRepository.upsertMetadata(asset.id, dto.metadata); + } + + if (sidecarFile) { + await this.assetRepository.upsertFile({ + assetId: asset.id, + path: sidecarFile.originalPath, + type: AssetFileType.Sidecar, + }); + await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); + } + await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); + await this.assetRepository.upsertExif({ + exif: { assetId: asset.id, fileSizeInByte: file.size }, + lockedPropertiesBehavior: 'override', + }); + + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); if (auth.sharedLink) { await this.addToSharedLink(auth.sharedLink, asset.id); } - await this.userRepository.updateUsage(auth.user.id, file.size); + await this.eventRepository.emit('AssetCreate', { asset, file }); return { id: asset.id, status: AssetMediaStatus.CREATED }; } catch (error: any) { - return this.handleUploadError(error, auth, file, sidecarFile); + // clean up files + await this.jobRepository.queue({ + name: JobName.FileDelete, + data: { files: [file.originalPath, sidecarFile?.originalPath] }, + }); + + // handle duplicates with a success response + if (isAssetChecksumConstraint(error)) { + const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); + if (!duplicateId) { + this.logger.error(`Error locating duplicate for checksum constraint`); + throw new InternalServerErrorException(); + } + + if (auth.sharedLink) { + await this.addToSharedLink(auth.sharedLink, duplicateId); + } + + this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`); + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; + } + + this.logger.error(`Error uploading file ${error}`, error?.stack); + throw error; } } @@ -285,84 +347,6 @@ export class AssetMediaService extends BaseService { : this.sharedLinkRepository.addAssets(sharedLink.id, [assetId])); } - private async handleUploadError( - error: any, - auth: AuthDto, - file: UploadFile, - sidecarFile?: UploadFile, - ): Promise { - // clean up files - await this.jobRepository.queue({ - name: JobName.FileDelete, - data: { files: [file.originalPath, sidecarFile?.originalPath] }, - }); - - // handle duplicates with a success response - if (isAssetChecksumConstraint(error)) { - const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); - if (!duplicateId) { - this.logger.error(`Error locating duplicate for checksum constraint`); - throw new InternalServerErrorException(); - } - - if (auth.sharedLink) { - await this.addToSharedLink(auth.sharedLink, duplicateId); - } - - this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`); - return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; - } - - this.logger.error(`Error uploading file ${error}`, error?.stack); - throw error; - } - - private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) { - const asset = await this.assetRepository.create({ - ownerId, - libraryId: null, - - checksum: file.checksum, - checksumAlgorithm: ChecksumAlgorithm.sha1File, - originalPath: file.originalPath, - - fileCreatedAt: dto.fileCreatedAt, - fileModifiedAt: dto.fileModifiedAt, - localDateTime: dto.fileCreatedAt, - - type: mimeTypes.assetType(file.originalPath), - isFavorite: dto.isFavorite, - duration: dto.duration || null, - visibility: dto.visibility ?? AssetVisibility.Timeline, - livePhotoVideoId: dto.livePhotoVideoId, - originalFileName: dto.filename || file.originalName, - }); - - if (dto.metadata?.length) { - await this.assetRepository.upsertMetadata(asset.id, dto.metadata); - } - - if (sidecarFile) { - await this.assetRepository.upsertFile({ - assetId: asset.id, - path: sidecarFile.originalPath, - type: AssetFileType.Sidecar, - }); - await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); - } - await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); - await this.assetRepository.upsertExif({ - exif: { assetId: asset.id, fileSizeInByte: file.size }, - lockedPropertiesBehavior: 'override', - }); - - await this.eventRepository.emit('AssetCreate', { asset }); - - await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); - - return asset; - } - private requireQuota(auth: AuthDto, size: number) { if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { throw new BadRequestException('Quota has been exceeded!'); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 82ab90a590..27084eb3b4 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -3,7 +3,7 @@ import { Updateable } from 'kysely'; import { DateTime } from 'luxon'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { OnJob } from 'src/decorators'; +import { OnEvent, OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto'; @@ -11,6 +11,7 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; import { UserFindOptions } from 'src/repositories/user.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { BaseService } from 'src/services/base.service'; @@ -230,6 +231,11 @@ export class UserService extends BaseService { }; } + @OnEvent({ name: 'AssetCreate' }) + async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) { + await this.userRepository.updateUsage(asset.ownerId, file.size); + } + @OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask }) async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts index 1cfe8e9f84..e9395bfebf 100644 --- a/server/test/medium/specs/services/asset-media.service.spec.ts +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -43,9 +43,11 @@ describe(AssetService.name, () => { ctx.getMock(EventRepository).emit.mockResolvedValue(); ctx.getMock(JobRepository).queue.mockResolvedValue(); + const fileSizeInByte = 12_345; + const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); - await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte }); const auth = factory.auth({ user: { id: user.id } }); await expect( @@ -56,7 +58,7 @@ describe(AssetService.name, () => { fileCreatedAt: new Date(), assetData: Buffer.from('some data'), }, - mediumFactory.uploadFile(), + mediumFactory.uploadFile({ size: fileSizeInByte }), ), ).resolves.toEqual({ id: expect.any(String), @@ -65,6 +67,7 @@ describe(AssetService.name, () => { expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', { asset: expect.objectContaining({}), + file: expect.objectContaining({ size: fileSizeInByte }), }); });