From 6c7679714b8a2e7e03ceb3491f35e27dd19eb986 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 25 Feb 2023 09:12:03 -0500 Subject: [PATCH] refactor(server): jobs and processors (#1787) * refactor: jobs and processors * refactor: storage migration processor * fix: tests * fix: code warning * chore: ignore coverage from infra * fix: sync move asset logic between job core and asset core * refactor: move error handling inside of catch * refactor(server): job core into dedicated service calls * refactor: smart info * fix: tests * chore: smart info tests * refactor: use asset repository * refactor: thumbnail processor * chore: coverage reqs --- .../src/api-v1/asset/asset-repository.ts | 6 + .../immich/src/api-v1/asset/asset.core.ts | 49 ++++- .../immich/src/api-v1/asset/asset.module.ts | 5 +- .../src/api-v1/asset/asset.service.spec.ts | 171 +++++++++++------- .../immich/src/api-v1/asset/asset.service.ts | 40 ++-- .../communication/communication.module.ts | 8 - .../apps/immich/src/api-v1/job/job.service.ts | 18 +- server/apps/immich/src/app.module.ts | 3 - .../schedule-tasks/schedule-tasks.service.ts | 23 +-- server/apps/immich/test/jest-e2e.json | 1 - .../microservices/src/microservices.module.ts | 48 ++--- server/apps/microservices/src/processors.ts | 87 +++++++++ .../processors/asset-uploaded.processor.ts | 13 -- .../processors/background-task.processor.ts | 17 -- .../processors/machine-learning.processor.ts | 68 ------- .../metadata-extraction.processor.ts | 65 +++---- .../processors/storage-migration.processor.ts | 61 ------- .../src/processors/thumbnail.processor.ts | 130 ------------- .../src/processors/user-deletion.processor.ts | 72 -------- .../processors/video-transcode.processor.ts | 13 +- server/libs/common/src/utils/asset-utils.ts | 2 +- server/libs/common/src/utils/index.ts | 1 - .../libs/common/src/utils/user-utils.spec.ts | 19 -- server/libs/common/src/utils/user-utils.ts | 16 -- .../libs/domain/src/album/album.repository.ts | 5 + server/libs/domain/src/album/index.ts | 1 + .../domain/src/api-key/api-key.repository.ts | 1 + .../libs/domain/src/asset/asset.repository.ts | 10 + .../domain/src/asset/asset.service.spec.ts | 45 +++++ server/libs/domain/src/asset/asset.service.ts | 18 ++ server/libs/domain/src/asset/index.ts | 2 + .../libs/domain/src/auth/auth.service.spec.ts | 23 ++- .../communication/communication.repository.ts | 9 + server/libs/domain/src/communication/index.ts | 1 + .../device-info/device-info.service.spec.ts | 2 +- server/libs/domain/src/domain.module.ts | 12 +- server/libs/domain/src/index.ts | 4 + server/libs/domain/src/job/index.ts | 3 +- .../interfaces/asset-uploaded.interface.ts | 13 -- .../interfaces/background-task.interface.ts | 5 - .../libs/domain/src/job/interfaces/index.ts | 7 - .../interfaces/machine-learning.interface.ts | 8 - .../metadata-extraction.interface.ts | 36 ---- .../thumbnail-generation.interface.ts | 17 -- .../job/interfaces/user-deletion.interface.ts | 8 - .../interfaces/video-transcode.interface.ts | 10 - server/libs/domain/src/job/job.constants.ts | 11 +- server/libs/domain/src/job/job.interface.ts | 26 +++ server/libs/domain/src/job/job.repository.ts | 42 ++--- .../libs/domain/src/job/job.service.spec.ts | 54 ------ server/libs/domain/src/job/job.service.ts | 17 -- server/libs/domain/src/job/job.upload.core.ts | 32 ---- server/libs/domain/src/media/index.ts | 2 + .../libs/domain/src/media/media.repository.ts | 12 ++ server/libs/domain/src/media/media.service.ts | 99 ++++++++++ .../domain/src/oauth/oauth.service.spec.ts | 12 -- server/libs/domain/src/smart-info/index.ts | 3 + .../smart-info/machine-learning.interface.ts | 10 + .../src/smart-info/smart-info.repository.ts | 7 + .../src/smart-info/smart-info.service.spec.ts | 102 +++++++++++ .../src/smart-info/smart-info.service.ts | 49 +++++ .../libs/domain/src/storage-template/index.ts | 2 + .../storage-template.core.ts} | 108 ++++------- .../storage-template.service.spec.ts | 149 +++++++++++++++ .../storage-template.service.ts | 73 ++++++++ server/libs/domain/src/storage/index.ts | 1 + .../domain/src/storage/storage.repository.ts | 6 + .../src/storage/storage.service.spec.ts | 39 ++++ .../domain/src/storage/storage.service.ts | 26 +++ .../system-config.service.spec.ts | 2 +- .../system-config/system-config.service.ts | 4 +- .../src/user-token/user-token.repository.ts | 1 + .../libs/domain/src/user/user.repository.ts | 3 +- .../libs/domain/src/user/user.service.spec.ts | 112 +++++++++++- server/libs/domain/src/user/user.service.ts | 86 ++++++++- .../libs/domain/test/album.repository.mock.ts | 7 + .../domain/test/api-key.repository.mock.ts | 1 + .../libs/domain/test/asset.repository.mock.ts | 10 + server/libs/domain/test/fixtures.ts | 45 ++++- server/libs/domain/test/index.ts | 4 + .../libs/domain/test/job.repository.mock.ts | 2 +- .../test/machine-learning.repository.mock.ts | 8 + server/libs/domain/test/setup.ts | 11 ++ .../domain/test/smart-info.repository.mock.ts | 7 + .../domain/test/storage.repository.mock.ts | 6 + .../domain/test/user-token.repository.mock.ts | 1 + .../libs/domain/test/user.repository.mock.ts | 1 + .../communication/communication.gateway.ts | 0 .../communication/communication.repository.ts | 12 ++ server/libs/infra/src/communication/index.ts | 2 + .../src/db/repository/album.repository.ts | 14 ++ .../src/db/repository/api-key.repository.ts | 4 + .../src/db/repository/asset.repository.ts | 38 ++++ server/libs/infra/src/db/repository/index.ts | 3 + .../db/repository/smart-info.repository.ts | 14 ++ .../db/repository/user-token.repository.ts | 6 +- .../src/db/repository/user.repository.ts | 14 +- server/libs/infra/src/infra.module.ts | 42 +++-- server/libs/infra/src/job/job.repository.ts | 46 ++--- .../libs/infra/src/machine-learning/index.ts | 1 + .../machine-learning.repository.ts | 17 ++ server/libs/infra/src/media/index.ts | 1 + .../libs/infra/src/media/media.repository.ts | 37 ++++ .../infra/src/storage/filesystem.provider.ts | 59 +++++- server/libs/storage/src/index.ts | 2 - server/libs/storage/src/storage.module.ts | 11 -- server/libs/storage/tsconfig.lib.json | 9 - server/package.json | 16 +- 108 files changed, 1645 insertions(+), 1072 deletions(-) delete mode 100644 server/apps/immich/src/api-v1/communication/communication.module.ts create mode 100644 server/apps/microservices/src/processors.ts delete mode 100644 server/apps/microservices/src/processors/asset-uploaded.processor.ts delete mode 100644 server/apps/microservices/src/processors/background-task.processor.ts delete mode 100644 server/apps/microservices/src/processors/machine-learning.processor.ts delete mode 100644 server/apps/microservices/src/processors/storage-migration.processor.ts delete mode 100644 server/apps/microservices/src/processors/thumbnail.processor.ts delete mode 100644 server/apps/microservices/src/processors/user-deletion.processor.ts delete mode 100644 server/libs/common/src/utils/user-utils.spec.ts delete mode 100644 server/libs/common/src/utils/user-utils.ts create mode 100644 server/libs/domain/src/album/album.repository.ts create mode 100644 server/libs/domain/src/asset/asset.repository.ts create mode 100644 server/libs/domain/src/asset/asset.service.spec.ts create mode 100644 server/libs/domain/src/asset/asset.service.ts create mode 100644 server/libs/domain/src/communication/communication.repository.ts create mode 100644 server/libs/domain/src/communication/index.ts delete mode 100644 server/libs/domain/src/job/interfaces/asset-uploaded.interface.ts delete mode 100644 server/libs/domain/src/job/interfaces/background-task.interface.ts delete mode 100644 server/libs/domain/src/job/interfaces/index.ts delete mode 100644 server/libs/domain/src/job/interfaces/machine-learning.interface.ts delete mode 100644 server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts delete mode 100644 server/libs/domain/src/job/interfaces/thumbnail-generation.interface.ts delete mode 100644 server/libs/domain/src/job/interfaces/user-deletion.interface.ts delete mode 100644 server/libs/domain/src/job/interfaces/video-transcode.interface.ts create mode 100644 server/libs/domain/src/job/job.interface.ts delete mode 100644 server/libs/domain/src/job/job.service.spec.ts delete mode 100644 server/libs/domain/src/job/job.service.ts delete mode 100644 server/libs/domain/src/job/job.upload.core.ts create mode 100644 server/libs/domain/src/media/index.ts create mode 100644 server/libs/domain/src/media/media.repository.ts create mode 100644 server/libs/domain/src/media/media.service.ts create mode 100644 server/libs/domain/src/smart-info/index.ts create mode 100644 server/libs/domain/src/smart-info/machine-learning.interface.ts create mode 100644 server/libs/domain/src/smart-info/smart-info.repository.ts create mode 100644 server/libs/domain/src/smart-info/smart-info.service.spec.ts create mode 100644 server/libs/domain/src/smart-info/smart-info.service.ts create mode 100644 server/libs/domain/src/storage-template/index.ts rename server/libs/{storage/src/storage.service.ts => domain/src/storage-template/storage-template.core.ts} (62%) create mode 100644 server/libs/domain/src/storage-template/storage-template.service.spec.ts create mode 100644 server/libs/domain/src/storage-template/storage-template.service.ts create mode 100644 server/libs/domain/src/storage/storage.service.spec.ts create mode 100644 server/libs/domain/src/storage/storage.service.ts create mode 100644 server/libs/domain/test/album.repository.mock.ts create mode 100644 server/libs/domain/test/asset.repository.mock.ts create mode 100644 server/libs/domain/test/machine-learning.repository.mock.ts create mode 100644 server/libs/domain/test/setup.ts create mode 100644 server/libs/domain/test/smart-info.repository.mock.ts rename server/{apps/immich/src/api-v1 => libs/infra/src}/communication/communication.gateway.ts (100%) create mode 100644 server/libs/infra/src/communication/communication.repository.ts create mode 100644 server/libs/infra/src/communication/index.ts create mode 100644 server/libs/infra/src/db/repository/album.repository.ts create mode 100644 server/libs/infra/src/db/repository/asset.repository.ts create mode 100644 server/libs/infra/src/db/repository/smart-info.repository.ts create mode 100644 server/libs/infra/src/machine-learning/index.ts create mode 100644 server/libs/infra/src/machine-learning/machine-learning.repository.ts create mode 100644 server/libs/infra/src/media/index.ts create mode 100644 server/libs/infra/src/media/media.repository.ts delete mode 100644 server/libs/storage/src/index.ts delete mode 100644 server/libs/storage/src/storage.module.ts delete mode 100644 server/libs/storage/tsconfig.lib.json diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 9f4ea7da49..85599761e3 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -23,6 +23,7 @@ export interface IAssetRepository { asset: Omit, ): Promise; remove(asset: AssetEntity): Promise; + save(asset: Partial): Promise; update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise; getAll(): Promise; @@ -292,6 +293,11 @@ export class AssetRepository implements IAssetRepository { await this.assetRepository.remove(asset); } + async save(asset: Partial): Promise { + const { id } = await this.assetRepository.save(asset); + return this.assetRepository.findOneOrFail({ where: { id } }); + } + /** * Update asset */ diff --git a/server/apps/immich/src/api-v1/asset/asset.core.ts b/server/apps/immich/src/api-v1/asset/asset.core.ts index f8b5c6ddb4..2481721519 100644 --- a/server/apps/immich/src/api-v1/asset/asset.core.ts +++ b/server/apps/immich/src/api-v1/asset/asset.core.ts @@ -1,15 +1,29 @@ -import { AuthUserDto, IJobRepository, JobName } from '@app/domain'; -import { AssetEntity, UserEntity } from '@app/infra/db/entities'; -import { StorageService } from '@app/storage'; +import { + AuthUserDto, + IJobRepository, + IStorageRepository, + ISystemConfigRepository, + JobName, + StorageTemplateCore, +} from '@app/domain'; +import { AssetEntity, SystemConfig, UserEntity } from '@app/infra/db/entities'; +import { Logger } from '@nestjs/common'; import { IAssetRepository } from './asset-repository'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; export class AssetCore { + private templateCore: StorageTemplateCore; + private logger = new Logger(AssetCore.name); + constructor( private repository: IAssetRepository, private jobRepository: IJobRepository, - private storageService: StorageService, - ) {} + configRepository: ISystemConfigRepository, + config: SystemConfig, + private storageRepository: IStorageRepository, + ) { + this.templateCore = new StorageTemplateCore(configRepository, config, storageRepository); + } async create( authUser: AuthUserDto, @@ -42,10 +56,31 @@ export class AssetCore { sharedLinks: [], }); - asset = await this.storageService.moveAsset(asset, file.originalName); + asset = await this.moveAsset(asset, file.originalName); - await this.jobRepository.add({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }); + await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }); return asset; } + + async moveAsset(asset: AssetEntity, originalName: string) { + const destination = await this.templateCore.getTemplatePath(asset, originalName); + if (asset.originalPath !== destination) { + const source = asset.originalPath; + + try { + await this.storageRepository.moveFile(asset.originalPath, destination); + try { + await this.repository.save({ id: asset.id, originalPath: destination }); + asset.originalPath = destination; + } catch (error: any) { + this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack); + await this.storageRepository.moveFile(destination, source); + } + } catch (error: any) { + this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination }); + } + } + return asset; + } } diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index d73542034f..d3a45d2d34 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -3,12 +3,10 @@ import { AssetService } from './asset.service'; import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AssetEntity } from '@app/infra'; -import { CommunicationModule } from '../communication/communication.module'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { DownloadModule } from '../../modules/download/download.module'; import { TagModule } from '../tag/tag.module'; import { AlbumModule } from '../album/album.module'; -import { StorageModule } from '@app/storage'; const ASSET_REPOSITORY_PROVIDER = { provide: IAssetRepository, @@ -17,11 +15,10 @@ const ASSET_REPOSITORY_PROVIDER = { @Module({ imports: [ + // TypeOrmModule.forFeature([AssetEntity]), - CommunicationModule, DownloadModule, TagModule, - StorageModule, AlbumModule, ], controllers: [AssetController], diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 305ee64c66..741ab1ac9a 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -8,19 +8,30 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { DownloadService } from '../../modules/download/download.service'; import { AlbumRepository, IAlbumRepository } from '../album/album-repository'; -import { StorageService } from '@app/storage'; -import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain'; import { + ICryptoRepository, + IJobRepository, + ISharedLinkRepository, + IStorageRepository, + ISystemConfigRepository, + JobName, +} from '@app/domain'; +import { + assetEntityStub, authStub, + fileStub, newCryptoRepositoryMock, newJobRepositoryMock, newSharedLinkRepositoryMock, newStorageRepositoryMock, + newSystemConfigRepositoryMock, sharedLinkResponseStub, sharedLinkStub, + systemConfigStub, } from '@app/domain/../test'; import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { when } from 'jest-when'; const _getCreateAssetDto = (): CreateAssetDto => { const createAssetDto = new CreateAssetDto(); @@ -109,8 +120,8 @@ describe('AssetService', () => { let assetRepositoryMock: jest.Mocked; let albumRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; - let storageServiceMock: jest.Mocked; let sharedLinkRepositoryMock: jest.Mocked; + let configMock: jest.Mocked; let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; let storageMock: jest.Mocked; @@ -120,6 +131,7 @@ describe('AssetService', () => { get: jest.fn(), create: jest.fn(), remove: jest.fn(), + save: jest.fn(), update: jest.fn(), getAll: jest.fn(), @@ -150,13 +162,9 @@ describe('AssetService', () => { downloadArchive: jest.fn(), }; - storageServiceMock = { - moveAsset: jest.fn(), - removeEmptyDirectories: jest.fn(), - } as unknown as jest.Mocked; - sharedLinkRepositoryMock = newSharedLinkRepositoryMock(); jobMock = newJobRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); storageMock = newStorageRepositoryMock(); @@ -165,12 +173,20 @@ describe('AssetService', () => { albumRepositoryMock, a, downloadServiceMock as DownloadService, - storageServiceMock, sharedLinkRepositoryMock, jobMock, + configMock, + systemConfigStub.defaults, cryptoMock, storageMock, ); + + when(assetRepositoryMock.get) + .calledWith(assetEntityStub.livePhotoStillAsset.id) + .mockResolvedValue(assetEntityStub.livePhotoStillAsset); + when(assetRepositoryMock.get) + .calledWith(assetEntityStub.livePhotoMotionAsset.id) + .mockResolvedValue(assetEntityStub.livePhotoMotionAsset); }); describe('createAssetsSharedLink', () => { @@ -255,10 +271,16 @@ describe('AssetService', () => { }; const dto = _getCreateAssetDto(); - assetRepositoryMock.create.mockImplementation(() => Promise.resolve(assetEntity)); - storageServiceMock.moveAsset.mockResolvedValue({ ...assetEntity, originalPath: 'fake_new_path/asset_123.jpeg' }); + assetRepositoryMock.create.mockResolvedValue(assetEntity); + assetRepositoryMock.save.mockResolvedValue(assetEntity); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); + + expect(assetRepositoryMock.create).toHaveBeenCalled(); + expect(assetRepositoryMock.save).toHaveBeenCalledWith({ + id: 'id_1', + originalPath: 'upload/user_id_1/2022/2022-06-19/asset_1.jpeg', + }); }); it('should handle a duplicate', async () => { @@ -277,59 +299,43 @@ describe('AssetService', () => { await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' }); - expect(jobMock.add).toHaveBeenCalledWith({ - name: JobName.DELETE_FILE_ON_DISK, - data: { assets: [{ originalPath: 'fake_path/asset_1.jpeg', resizePath: null }] }, + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); - expect(storageServiceMock.moveAsset).not.toHaveBeenCalled(); + expect(storageMock.moveFile).not.toHaveBeenCalled(); }); it('should handle a live photo', async () => { - const file = { - originalPath: 'fake_path/asset_1.jpeg', - mimeType: 'image/jpeg', - checksum: Buffer.from('file hash', 'utf8'), - originalName: 'asset_1.jpeg', - }; - const asset = { - id: 'live-photo-asset', - originalPath: file.originalPath, - ownerId: authStub.user1.id, - type: AssetType.IMAGE, - isVisible: true, - } as AssetEntity; - - const livePhotoFile = { - originalPath: 'fake_path/asset_1.mp4', - mimeType: 'image/jpeg', - checksum: Buffer.from('live photo file hash', 'utf8'), - originalName: 'asset_1.jpeg', - }; - - const livePhotoAsset = { - id: 'live-photo-motion', - originalPath: livePhotoFile.originalPath, - ownerId: authStub.user1.id, - type: AssetType.VIDEO, - isVisible: false, - } as AssetEntity; - const dto = _getCreateAssetDto(); const error = new QueryFailedError('', [], ''); (error as any).constraint = 'UQ_userid_checksum'; - assetRepositoryMock.create.mockResolvedValueOnce(livePhotoAsset); - assetRepositoryMock.create.mockResolvedValueOnce(asset); - storageServiceMock.moveAsset.mockImplementation((asset) => Promise.resolve(asset)); + assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoMotionAsset); + assetRepositoryMock.save.mockResolvedValueOnce(assetEntityStub.livePhotoMotionAsset); + assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoStillAsset); + assetRepositoryMock.save.mockResolvedValueOnce(assetEntityStub.livePhotoStillAsset); - await expect(sut.uploadFile(authStub.user1, dto, file, livePhotoFile)).resolves.toEqual({ + await expect( + sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion), + ).resolves.toEqual({ duplicate: false, - id: 'live-photo-asset', + id: 'live-photo-still-asset', }); - expect(jobMock.add.mock.calls).toEqual([ - [{ name: JobName.ASSET_UPLOADED, data: { asset: livePhotoAsset, fileName: file.originalName } }], - [{ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }], + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.ASSET_UPLOADED, + data: { asset: assetEntityStub.livePhotoMotionAsset, fileName: 'asset_1.mp4' }, + }, + ], + [ + { + name: JobName.ASSET_UPLOADED, + data: { asset: assetEntityStub.livePhotoStillAsset, fileName: 'asset_1.jpeg' }, + }, + ], ]); }); }); @@ -383,7 +389,7 @@ describe('AssetService', () => { { id: 'asset1', status: 'FAILED' }, ]); - expect(jobMock.add).not.toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should return failed status a delete fails', async () => { @@ -394,35 +400,66 @@ describe('AssetService', () => { { id: 'asset1', status: 'FAILED' }, ]); - expect(jobMock.add).not.toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should delete a live photo', async () => { - assetRepositoryMock.get.mockResolvedValueOnce({ id: 'asset1', livePhotoVideoId: 'live-photo' } as AssetEntity); - assetRepositoryMock.get.mockResolvedValueOnce({ id: 'live-photo' } as AssetEntity); - - await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([ - { id: 'asset1', status: 'SUCCESS' }, - { id: 'live-photo', status: 'SUCCESS' }, + await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([ + { id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' }, + { id: assetEntityStub.livePhotoMotionAsset.id, status: 'SUCCESS' }, ]); - expect(jobMock.add).toHaveBeenCalledWith({ - name: JobName.DELETE_FILE_ON_DISK, - data: { assets: [{ id: 'asset1', livePhotoVideoId: 'live-photo' }, { id: 'live-photo' }] }, + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { + files: ['fake_path/asset_1.jpeg', undefined, undefined, 'fake_path/asset_1.mp4', undefined, undefined], + }, }); }); it('should delete a batch of assets', async () => { - assetRepositoryMock.get.mockImplementation((id) => Promise.resolve({ id } as AssetEntity)); - assetRepositoryMock.remove.mockImplementation(() => Promise.resolve()); + const asset1 = { + id: 'asset1', + originalPath: 'original-path-1', + resizePath: 'resize-path-1', + webpPath: 'web-path-1', + }; + + const asset2 = { + id: 'asset2', + originalPath: 'original-path-2', + resizePath: 'resize-path-2', + webpPath: 'web-path-2', + }; + + when(assetRepositoryMock.get) + .calledWith(asset1.id) + .mockResolvedValue(asset1 as AssetEntity); + when(assetRepositoryMock.get) + .calledWith(asset2.id) + .mockResolvedValue(asset2 as AssetEntity); await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([ { id: 'asset1', status: 'SUCCESS' }, { id: 'asset2', status: 'SUCCESS' }, ]); - expect(jobMock.add.mock.calls).toEqual([ - [{ name: JobName.DELETE_FILE_ON_DISK, data: { assets: [{ id: 'asset1' }, { id: 'asset2' }] } }], + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.DELETE_FILES, + data: { + files: [ + 'original-path-1', + 'web-path-1', + 'resize-path-1', + 'original-path-2', + 'web-path-2', + 'resize-path-2', + ], + }, + }, + ], ]); }); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 7f696ddaec..a7dc920e0c 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -12,7 +12,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { QueryFailedError, Repository } from 'typeorm'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { AssetEntity, AssetType, SharedLinkType } from '@app/infra'; +import { AssetEntity, AssetType, SharedLinkType, SystemConfig } from '@app/infra'; import { constants, createReadStream, ReadStream, stat } from 'fs'; import { ServeFileDto } from './dto/serve-file.dto'; import { Response as Res } from 'express'; @@ -25,7 +25,9 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon import { AssetResponseDto, ImmichReadStream, + INITIAL_SYSTEM_CONFIG, IStorageRepository, + ISystemConfigRepository, JobName, mapAsset, mapAssetWithoutExif, @@ -52,7 +54,6 @@ import { ICryptoRepository, IJobRepository } from '@app/domain'; import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from './dto/download-library.dto'; import { IAlbumRepository } from '../album/album-repository'; -import { StorageService } from '@app/storage'; import { ShareCore } from '@app/domain'; import { ISharedLinkRepository } from '@app/domain'; import { DownloadFilesDto } from './dto/download-files.dto'; @@ -61,6 +62,8 @@ import { mapSharedLink, SharedLinkResponseDto } from '@app/domain'; import { AssetSearchDto } from './dto/asset-search.dto'; import { AddAssetsDto } from '../album/dto/add-assets.dto'; import { RemoveAssetsDto } from '../album/dto/remove-assets.dto'; +import path from 'path'; +import { getFileNameWithoutExtension } from '../../utils/file-name.util'; const fileInfo = promisify(stat); @@ -76,13 +79,14 @@ export class AssetService { @InjectRepository(AssetEntity) private assetRepository: Repository, private downloadService: DownloadService, - storageService: StorageService, @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(IStorageRepository) private storage: IStorageRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { - this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService); + this.assetCore = new AssetCore(_assetRepository, jobRepository, configRepository, config, storageRepository); this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); } @@ -93,7 +97,10 @@ export class AssetService { livePhotoFile?: UploadFile, ): Promise { if (livePhotoFile) { - livePhotoFile.originalName = file.originalName; + livePhotoFile = { + ...livePhotoFile, + originalName: getFileNameWithoutExtension(file.originalName) + path.extname(livePhotoFile.originalName), + }; } let livePhotoAsset: AssetEntity | null = null; @@ -109,16 +116,9 @@ export class AssetService { return { id: asset.id, duplicate: false }; } catch (error: any) { // clean up files - await this.jobRepository.add({ - name: JobName.DELETE_FILE_ON_DISK, - data: { - assets: [ - { - originalPath: file.originalPath, - resizePath: livePhotoFile?.originalPath || null, - } as AssetEntity, - ], - }, + await this.jobRepository.queue({ + name: JobName.DELETE_FILES, + data: { files: [file.originalPath, livePhotoFile?.originalPath] }, }); // handle duplicates with a success response @@ -204,7 +204,7 @@ export class AssetService { try { const asset = await this._assetRepository.get(assetId); if (asset && asset.originalPath && asset.mimeType) { - return this.storage.createReadStream(asset.originalPath, asset.mimeType); + return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); } } catch (e) { Logger.error(`Error download asset ${e}`, 'downloadFile'); @@ -412,7 +412,7 @@ export class AssetService { } public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise { - const deleteQueue: AssetEntity[] = []; + const deleteQueue: Array = []; const result: DeleteAssetResponseDto[] = []; const ids = dto.ids.slice(); @@ -427,7 +427,7 @@ export class AssetService { await this._assetRepository.remove(asset); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); - deleteQueue.push(asset as any); + deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath); // TODO refactor this to use cascades if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { @@ -439,7 +439,7 @@ export class AssetService { } if (deleteQueue.length > 0) { - await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets: deleteQueue } }); + await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: deleteQueue } }); } return result; diff --git a/server/apps/immich/src/api-v1/communication/communication.module.ts b/server/apps/immich/src/api-v1/communication/communication.module.ts deleted file mode 100644 index 946a2f8b3c..0000000000 --- a/server/apps/immich/src/api-v1/communication/communication.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CommunicationGateway } from './communication.gateway'; - -@Module({ - providers: [CommunicationGateway], - exports: [CommunicationGateway], -}) -export class CommunicationModule {} diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts index ca31d3562e..f41997ff1a 100644 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ b/server/apps/immich/src/api-v1/job/job.service.ts @@ -48,14 +48,14 @@ export class JobService { ? await this._assetRepository.getAllVideos() : await this._assetRepository.getAssetWithNoEncodedVideo(); for (const asset of assets) { - await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } }); } return assets.length; } - case QueueName.CONFIG: - await this.jobRepository.add({ name: JobName.TEMPLATE_MIGRATION }); + case QueueName.STORAGE_TEMPLATE_MIGRATION: + await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); return 1; case QueueName.MACHINE_LEARNING: { @@ -68,8 +68,8 @@ export class JobService { : await this._assetRepository.getAssetWithNoSmartInfo(); for (const asset of assets) { - await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } }); - await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } }); + await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } }); + await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } }); } return assets.length; } @@ -81,7 +81,7 @@ export class JobService { for (const asset of assets) { if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ + await this.jobRepository.queue({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, @@ -89,7 +89,7 @@ export class JobService { }, }); } else { - await this.jobRepository.add({ + await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data: { asset, @@ -107,7 +107,7 @@ export class JobService { : await this._assetRepository.getAssetWithNoThumbnail(); for (const asset of assets) { - await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); + await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); } return assets.length; } @@ -129,7 +129,7 @@ export class JobService { return QueueName.VIDEO_CONVERSION; case JobId.STORAGE_TEMPLATE_MIGRATION: - return QueueName.CONFIG; + return QueueName.STORAGE_TEMPLATE_MIGRATION; case JobId.MACHINE_LEARNING: return QueueName.MACHINE_LEARNING; diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 8a7f73155c..5d07765043 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -3,7 +3,6 @@ import { Module } from '@nestjs/common'; import { AssetModule } from './api-v1/asset/asset.module'; import { ConfigModule } from '@nestjs/config'; import { ServerInfoModule } from './api-v1/server-info/server-info.module'; -import { CommunicationModule } from './api-v1/communication/communication.module'; import { AlbumModule } from './api-v1/album/album.module'; import { AppController } from './app.controller'; import { ScheduleModule } from '@nestjs/schedule'; @@ -36,8 +35,6 @@ import { AuthGuard } from './middlewares/auth.guard'; ServerInfoModule, - CommunicationModule, - AlbumModule, ScheduleModule.forRoot(), diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts index 55b7ce6864..bd8a7715e7 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts @@ -1,26 +1,13 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { UserService } from '@app/domain'; +import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Not, Repository } from 'typeorm'; -import { UserEntity } from '@app/infra'; -import { userUtils } from '@app/common'; -import { IJobRepository, JobName } from '@app/domain'; @Injectable() export class ScheduleTasksService { - constructor( - @InjectRepository(UserEntity) - private userRepository: Repository, + constructor(private userService: UserService) {} - @Inject(IJobRepository) private jobRepository: IJobRepository, - ) {} @Cron(CronExpression.EVERY_DAY_AT_11PM) - async deleteUserAndRelatedAssets() { - const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); - for (const user of usersToDelete) { - if (userUtils.isReadyForDeletion(user)) { - await this.jobRepository.add({ name: JobName.USER_DELETION, data: { user } }); - } - } + async onUserDeleteCheck() { + await this.userService.handleUserDeleteCheck(); } } diff --git a/server/apps/immich/test/jest-e2e.json b/server/apps/immich/test/jest-e2e.json index 6867cf956e..1d8ef78bba 100644 --- a/server/apps/immich/test/jest-e2e.json +++ b/server/apps/immich/test/jest-e2e.json @@ -9,7 +9,6 @@ }, "moduleNameMapper": { "^@app/common": "../../../libs/common/src", - "^@app/storage(|/.*)$": "../../../libs/storage/src/$1", "^@app/infra(|/.*)$": "../../../libs/infra/src/$1", "^@app/domain(|/.*)$": "../../../libs/domain/src/$1" } diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 0439f4e7f5..845f6c158e 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -1,56 +1,30 @@ import { immichAppConfig } from '@app/common/config'; -import { - AssetEntity, - ExifEntity, - SmartInfoEntity, - UserEntity, - APIKeyEntity, - InfraModule, - UserTokenEntity, - AlbumEntity, -} from '@app/infra'; -import { StorageModule } from '@app/storage'; +import { DomainModule } from '@app/domain'; +import { ExifEntity, InfraModule } from '@app/infra'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; -import { AssetUploadedProcessor } from './processors/asset-uploaded.processor'; -import { MachineLearningProcessor } from './processors/machine-learning.processor'; +import { + BackgroundTaskProcessor, + MachineLearningProcessor, + StorageTemplateMigrationProcessor, + ThumbnailGeneratorProcessor, +} from './processors'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; -import { StorageMigrationProcessor } from './processors/storage-migration.processor'; -import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; -import { UserDeletionProcessor } from './processors/user-deletion.processor'; import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; -import { BackgroundTaskProcessor } from './processors/background-task.processor'; -import { DomainModule } from '@app/domain'; @Module({ imports: [ ConfigModule.forRoot(immichAppConfig), - DomainModule.register({ - imports: [InfraModule], - }), - TypeOrmModule.forFeature([ - UserEntity, - ExifEntity, - AssetEntity, - SmartInfoEntity, - APIKeyEntity, - UserTokenEntity, - AlbumEntity, - ]), - StorageModule, - CommunicationModule, + DomainModule.register({ imports: [InfraModule] }), + TypeOrmModule.forFeature([ExifEntity]), ], - controllers: [], providers: [ - AssetUploadedProcessor, ThumbnailGeneratorProcessor, MetadataExtractionProcessor, VideoTranscodeProcessor, MachineLearningProcessor, - UserDeletionProcessor, - StorageMigrationProcessor, + StorageTemplateMigrationProcessor, BackgroundTaskProcessor, ], }) diff --git a/server/apps/microservices/src/processors.ts b/server/apps/microservices/src/processors.ts new file mode 100644 index 0000000000..00e88b90d8 --- /dev/null +++ b/server/apps/microservices/src/processors.ts @@ -0,0 +1,87 @@ +import { + AssetService, + IAssetJob, + IAssetUploadedJob, + IDeleteFilesJob, + IUserDeletionJob, + JobName, + MediaService, + QueueName, + SmartInfoService, + StorageService, + StorageTemplateService, + SystemConfigService, + UserService, +} from '@app/domain'; +import { Process, Processor } from '@nestjs/bull'; +import { Job } from 'bull'; + +@Processor(QueueName.BACKGROUND_TASK) +export class BackgroundTaskProcessor { + constructor( + private assetService: AssetService, + private storageService: StorageService, + private systemConfigService: SystemConfigService, + private userService: UserService, + ) {} + + @Process(JobName.ASSET_UPLOADED) + async onAssetUpload(job: Job) { + await this.assetService.handleAssetUpload(job.data); + } + + @Process(JobName.DELETE_FILES) + async onDeleteFile(job: Job) { + await this.storageService.handleDeleteFiles(job.data); + } + + @Process(JobName.SYSTEM_CONFIG_CHANGE) + async onSystemConfigChange() { + await this.systemConfigService.refreshConfig(); + } + + @Process(JobName.USER_DELETION) + async onUserDelete(job: Job) { + await this.userService.handleUserDelete(job.data); + } +} + +@Processor(QueueName.MACHINE_LEARNING) +export class MachineLearningProcessor { + constructor(private smartInfoService: SmartInfoService) {} + + @Process({ name: JobName.IMAGE_TAGGING, concurrency: 2 }) + async onTagImage(job: Job) { + await this.smartInfoService.handleTagImage(job.data); + } + + @Process({ name: JobName.OBJECT_DETECTION, concurrency: 2 }) + async onDetectObject(job: Job) { + await this.smartInfoService.handleDetectObjects(job.data); + } +} + +@Processor(QueueName.STORAGE_TEMPLATE_MIGRATION) +export class StorageTemplateMigrationProcessor { + constructor(private storageTemplateService: StorageTemplateService) {} + + @Process({ name: JobName.STORAGE_TEMPLATE_MIGRATION }) + async onTemplateMigration() { + await this.storageTemplateService.handleTemplateMigration(); + } +} + +@Processor(QueueName.THUMBNAIL_GENERATION) +export class ThumbnailGeneratorProcessor { + constructor(private mediaService: MediaService) {} + + @Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 }) + async handleGenerateJpegThumbnail(job: Job) { + await this.mediaService.handleGenerateJpegThumbnail(job.data); + } + + @Process({ name: JobName.GENERATE_WEBP_THUMBNAIL, concurrency: 3 }) + async handleGenerateWepbThumbnail(job: Job) { + await this.mediaService.handleGenerateWepbThumbnail(job.data); + } +} diff --git a/server/apps/microservices/src/processors/asset-uploaded.processor.ts b/server/apps/microservices/src/processors/asset-uploaded.processor.ts deleted file mode 100644 index 4c402ee4f4..0000000000 --- a/server/apps/microservices/src/processors/asset-uploaded.processor.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IAssetUploadedJob, JobName, JobService, QueueName } from '@app/domain'; -import { Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; - -@Processor(QueueName.ASSET_UPLOADED) -export class AssetUploadedProcessor { - constructor(private jobService: JobService) {} - - @Process(JobName.ASSET_UPLOADED) - async processUploadedVideo(job: Job) { - await this.jobService.handleUploadedAsset(job); - } -} diff --git a/server/apps/microservices/src/processors/background-task.processor.ts b/server/apps/microservices/src/processors/background-task.processor.ts deleted file mode 100644 index 34799044f3..0000000000 --- a/server/apps/microservices/src/processors/background-task.processor.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { assetUtils } from '@app/common/utils'; -import { Process, Processor } from '@nestjs/bull'; -import { Job } from 'bull'; -import { JobName, QueueName } from '@app/domain'; -import { AssetEntity } from '@app/infra/db/entities'; - -@Processor(QueueName.BACKGROUND_TASK) -export class BackgroundTaskProcessor { - @Process(JobName.DELETE_FILE_ON_DISK) - async deleteFileOnDisk(job: Job<{ assets: AssetEntity[] }>) { - const { assets } = job.data; - - for (const asset of assets) { - assetUtils.deleteFiles(asset); - } - } -} diff --git a/server/apps/microservices/src/processors/machine-learning.processor.ts b/server/apps/microservices/src/processors/machine-learning.processor.ts deleted file mode 100644 index 54c3ce0ad7..0000000000 --- a/server/apps/microservices/src/processors/machine-learning.processor.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AssetEntity } from '@app/infra'; -import { SmartInfoEntity } from '@app/infra'; -import { QueueName, JobName } from '@app/domain'; -import { IMachineLearningJob } from '@app/domain'; -import { Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import axios from 'axios'; -import { Job } from 'bull'; -import { Repository } from 'typeorm'; -import { MACHINE_LEARNING_ENABLED, MACHINE_LEARNING_URL } from '@app/common'; - -@Processor(QueueName.MACHINE_LEARNING) -export class MachineLearningProcessor { - constructor( - @InjectRepository(SmartInfoEntity) - private smartInfoRepository: Repository, - ) {} - - @Process({ name: JobName.IMAGE_TAGGING, concurrency: 2 }) - async tagImage(job: Job) { - if (!MACHINE_LEARNING_ENABLED) { - return; - } - - const { asset } = job.data; - - const res = await axios.post(MACHINE_LEARNING_URL + '/image-classifier/tag-image', { - thumbnailPath: asset.resizePath, - }); - - if (res.status == 201 && res.data.length > 0) { - const smartInfo = new SmartInfoEntity(); - smartInfo.assetId = asset.id; - smartInfo.tags = [...res.data]; - await this.smartInfoRepository.upsert(smartInfo, { - conflictPaths: ['assetId'], - }); - } - } - - @Process({ name: JobName.OBJECT_DETECTION, concurrency: 2 }) - async detectObject(job: Job) { - if (!MACHINE_LEARNING_ENABLED) { - return; - } - - try { - const { asset }: { asset: AssetEntity } = job.data; - - const res = await axios.post(MACHINE_LEARNING_URL + '/object-detection/detect-object', { - thumbnailPath: asset.resizePath, - }); - - if (res.status == 201 && res.data.length > 0) { - const smartInfo = new SmartInfoEntity(); - smartInfo.assetId = asset.id; - smartInfo.objects = [...res.data]; - - await this.smartInfoRepository.upsert(smartInfo, { - conflictPaths: ['assetId'], - }); - } - } catch (error) { - Logger.error(`Failed to trigger object detection pipe line ${String(error)}`); - } - } -} diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 6308a42d16..a5a7f0d3bc 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -1,13 +1,7 @@ import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; -import { - IExifExtractionProcessor, - IReverseGeocodingProcessor, - IVideoLengthExtractionProcessor, - QueueName, - JobName, -} from '@app/domain'; +import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain'; import { Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; +import { Inject, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Job } from 'bull'; @@ -19,7 +13,6 @@ import geocoder, { InitOptions } from 'local-reverse-geocoder'; import { getName } from 'i18n-iso-countries'; import fs from 'node:fs'; import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored'; -import { IsNull, Not } from 'typeorm'; interface ImmichTags extends Tags { ContentIdentifier?: string; @@ -79,9 +72,7 @@ export class MetadataExtractionProcessor { private logger = new Logger(MetadataExtractionProcessor.name); private isGeocodeInitialized = false; constructor( - @InjectRepository(AssetEntity) - private assetRepository: Repository, - + @Inject(IAssetRepository) private assetRepository: IAssetRepository, @InjectRepository(ExifEntity) private exifRepository: Repository, @@ -141,7 +132,7 @@ export class MetadataExtractionProcessor { } @Process(JobName.EXIF_EXTRACTION) - async extractExifInfo(job: Job) { + async extractExifInfo(job: Job) { try { const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data; const exifData = await exiftool.read(asset.originalPath).catch((e) => { @@ -190,22 +181,14 @@ export class MetadataExtractionProcessor { }); if (newExif.livePhotoCID && !asset.livePhotoVideoId) { - const motionAsset = await this.assetRepository.findOne({ - where: { - id: Not(asset.id), - type: AssetType.VIDEO, - exifInfo: { - livePhotoCID: newExif.livePhotoCID, - }, - }, - relations: { - exifInfo: true, - }, - }); - + const motionAsset = await this.assetRepository.findLivePhotoMatch( + newExif.livePhotoCID, + AssetType.VIDEO, + asset.id, + ); if (motionAsset) { - await this.assetRepository.update(asset.id, { livePhotoVideoId: motionAsset.id }); - await this.assetRepository.update(motionAsset.id, { isVisible: false }); + await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); + await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); } } @@ -249,7 +232,7 @@ export class MetadataExtractionProcessor { } @Process({ name: JobName.REVERSE_GEOCODING }) - async reverseGeocoding(job: Job) { + async reverseGeocoding(job: Job) { if (this.isGeocodeInitialized) { const { latitude, longitude } = job.data; const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude); @@ -258,7 +241,7 @@ export class MetadataExtractionProcessor { } @Process({ name: JobName.EXTRACT_VIDEO_METADATA, concurrency: 2 }) - async extractVideoMetadata(job: Job) { + async extractVideoMetadata(job: Job) { const { asset, fileName } = job.data; if (!asset.isVisible) { @@ -309,20 +292,14 @@ export class MetadataExtractionProcessor { newExif.livePhotoCID = exifData?.ContentIdentifier || null; if (newExif.livePhotoCID) { - const photoAsset = await this.assetRepository.findOne({ - where: { - id: Not(asset.id), - type: AssetType.IMAGE, - livePhotoVideoId: IsNull(), - exifInfo: { - livePhotoCID: newExif.livePhotoCID, - }, - }, - }); - + const photoAsset = await this.assetRepository.findLivePhotoMatch( + newExif.livePhotoCID, + AssetType.IMAGE, + asset.id, + ); if (photoAsset) { - await this.assetRepository.update(photoAsset.id, { livePhotoVideoId: asset.id }); - await this.assetRepository.update(asset.id, { isVisible: false }); + await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); + await this.assetRepository.save({ id: asset.id, isVisible: false }); } } @@ -378,7 +355,7 @@ export class MetadataExtractionProcessor { } await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); - await this.assetRepository.update({ id: asset.id }, { duration: durationString, fileCreatedAt }); + await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt }); } catch (err) { ``; // do nothing diff --git a/server/apps/microservices/src/processors/storage-migration.processor.ts b/server/apps/microservices/src/processors/storage-migration.processor.ts deleted file mode 100644 index ef5c9d87ef..0000000000 --- a/server/apps/microservices/src/processors/storage-migration.processor.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { APP_UPLOAD_LOCATION } from '@app/common'; -import { AssetEntity } from '@app/infra'; -import { SystemConfigService } from '@app/domain'; -import { QueueName, JobName } from '@app/domain'; -import { StorageService } from '@app/storage'; -import { Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -@Processor(QueueName.CONFIG) -export class StorageMigrationProcessor { - readonly logger: Logger = new Logger(StorageMigrationProcessor.name); - - constructor( - private storageService: StorageService, - private systemConfigService: SystemConfigService, - - @InjectRepository(AssetEntity) - private assetRepository: Repository, - ) {} - - /** - * Migration process when a new user set a new storage template. - * @param job - */ - @Process({ name: JobName.TEMPLATE_MIGRATION, concurrency: 100 }) - async templateMigration() { - console.time('migrating-time'); - const assets = await this.assetRepository.find({ - relations: ['exifInfo'], - }); - - const livePhotoMap: Record = {}; - - for (const asset of assets) { - if (asset.livePhotoVideoId) { - livePhotoMap[asset.livePhotoVideoId] = asset; - } - } - - for (const asset of assets) { - const livePhotoParentAsset = livePhotoMap[asset.id]; - const filename = asset.exifInfo?.imageName || livePhotoParentAsset?.exifInfo?.imageName || asset.id; - await this.storageService.moveAsset(asset, filename); - } - - await this.storageService.removeEmptyDirectories(APP_UPLOAD_LOCATION); - console.timeEnd('migrating-time'); - } - - /** - * Update config when a new storage template is set. - * This is to ensure the synchronization between processes. - * @param job - */ - @Process({ name: JobName.CONFIG_CHANGE, concurrency: 1 }) - async updateTemplate() { - await this.systemConfigService.refreshConfig(); - } -} diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts deleted file mode 100644 index fd0387588a..0000000000 --- a/server/apps/microservices/src/processors/thumbnail.processor.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { APP_UPLOAD_LOCATION } from '@app/common'; -import { AssetEntity, AssetType } from '@app/infra'; -import { WebpGeneratorProcessor, JpegGeneratorProcessor, QueueName, JobName } from '@app/domain'; -import { InjectQueue, Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { mapAsset } from '@app/domain'; -import { Job, Queue } from 'bull'; -import ffmpeg from 'fluent-ffmpeg'; -import { existsSync, mkdirSync } from 'node:fs'; -import sanitize from 'sanitize-filename'; -import sharp from 'sharp'; -import { Repository } from 'typeorm/repository/Repository'; -import { join } from 'path'; -import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway'; -import { IMachineLearningJob } from '@app/domain'; -import { exiftool } from 'exiftool-vendored'; - -@Processor(QueueName.THUMBNAIL_GENERATION) -export class ThumbnailGeneratorProcessor { - readonly logger: Logger = new Logger(ThumbnailGeneratorProcessor.name); - - constructor( - @InjectRepository(AssetEntity) - private assetRepository: Repository, - - @InjectQueue(QueueName.THUMBNAIL_GENERATION) - private thumbnailGeneratorQueue: Queue, - - private wsCommunicationGateway: CommunicationGateway, - - @InjectQueue(QueueName.MACHINE_LEARNING) - private machineLearningQueue: Queue, - ) {} - - @Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 }) - async generateJPEGThumbnail(job: Job) { - const basePath = APP_UPLOAD_LOCATION; - - const { asset } = job.data; - const sanitizedDeviceId = sanitize(String(asset.deviceId)); - - const resizePath = join(basePath, asset.ownerId, 'thumb', sanitizedDeviceId); - - if (!existsSync(resizePath)) { - mkdirSync(resizePath, { recursive: true }); - } - - const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); - - if (asset.type == AssetType.IMAGE) { - try { - await sharp(asset.originalPath, { failOnError: false }) - .resize(1440, 1440, { fit: 'outside', withoutEnlargement: true }) - .jpeg() - .rotate() - .toFile(jpegThumbnailPath) - .catch(() => { - this.logger.warn( - 'Failed to generate jpeg thumbnail for asset: ' + - asset.id + - ' using sharp, failing over to exiftool-vendored', - ); - return exiftool.extractThumbnail(asset.originalPath, jpegThumbnailPath); - }); - await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); - } catch (error: any) { - this.logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id, error.stack); - } - - // Update resize path to send to generate webp queue - asset.resizePath = jpegThumbnailPath; - - await this.thumbnailGeneratorQueue.add(JobName.GENERATE_WEBP_THUMBNAIL, { asset }); - await this.machineLearningQueue.add(JobName.IMAGE_TAGGING, { asset }); - await this.machineLearningQueue.add(JobName.OBJECT_DETECTION, { asset }); - - this.wsCommunicationGateway.server.to(asset.ownerId).emit('on_upload_success', JSON.stringify(mapAsset(asset))); - } - - if (asset.type == AssetType.VIDEO) { - await new Promise((resolve, reject) => { - ffmpeg(asset.originalPath) - .outputOptions(['-ss 00:00:00.000', '-frames:v 1']) - .output(jpegThumbnailPath) - .on('start', () => { - Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail'); - }) - .on('error', (error) => { - Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail'); - reject(error); - }) - .on('end', async () => { - Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail'); - resolve(asset); - }) - .run(); - }); - - await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); - - // Update resize path to send to generate webp queue - asset.resizePath = jpegThumbnailPath; - - await this.thumbnailGeneratorQueue.add(JobName.GENERATE_WEBP_THUMBNAIL, { asset }); - await this.machineLearningQueue.add(JobName.IMAGE_TAGGING, { asset }); - await this.machineLearningQueue.add(JobName.OBJECT_DETECTION, { asset }); - - this.wsCommunicationGateway.server.to(asset.ownerId).emit('on_upload_success', JSON.stringify(mapAsset(asset))); - } - } - - @Process({ name: JobName.GENERATE_WEBP_THUMBNAIL, concurrency: 3 }) - async generateWepbThumbnail(job: Job) { - const { asset } = job.data; - - if (!asset.resizePath) { - return; - } - - const webpPath = asset.resizePath.replace('jpeg', 'webp'); - - try { - await sharp(asset.resizePath, { failOnError: false }).resize(250).webp().rotate().toFile(webpPath); - await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); - } catch (error: any) { - this.logger.error('Failed to generate webp thumbnail for asset: ' + asset.id, error.stack); - } - } -} diff --git a/server/apps/microservices/src/processors/user-deletion.processor.ts b/server/apps/microservices/src/processors/user-deletion.processor.ts deleted file mode 100644 index 756b402758..0000000000 --- a/server/apps/microservices/src/processors/user-deletion.processor.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { APP_UPLOAD_LOCATION, userUtils } from '@app/common'; -import { AlbumEntity, APIKeyEntity, AssetEntity, UserEntity, UserTokenEntity } from '@app/infra'; -import { QueueName, JobName } from '@app/domain'; -import { IUserDeletionJob } from '@app/domain'; -import { Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Job } from 'bull'; -import { join } from 'path'; -import fs from 'fs'; -import { Repository } from 'typeorm'; - -@Processor(QueueName.USER_DELETION) -export class UserDeletionProcessor { - private logger = new Logger(UserDeletionProcessor.name); - - constructor( - @InjectRepository(UserEntity) - private userRepository: Repository, - - @InjectRepository(AssetEntity) - private assetRepository: Repository, - - @InjectRepository(APIKeyEntity) - private apiKeyRepository: Repository, - - @InjectRepository(UserTokenEntity) - private userTokenRepository: Repository, - - @InjectRepository(AlbumEntity) - private albumRepository: Repository, - ) {} - - @Process(JobName.USER_DELETION) - async processUserDeletion(job: Job) { - const { user } = job.data; - - // just for extra protection here - if (!userUtils.isReadyForDeletion(user)) { - this.logger.warn(`Skipped user that was not ready for deletion: id=${user.id}`); - return; - } - - this.logger.log(`Deleting user: ${user.id}`); - - try { - const basePath = APP_UPLOAD_LOCATION; - const userAssetDir = join(basePath, user.id); - this.logger.warn(`Removing user from filesystem: ${userAssetDir}`); - fs.rmSync(userAssetDir, { recursive: true, force: true }); - - this.logger.warn(`Removing user from database: ${user.id}`); - const userTokens = await this.userTokenRepository.find({ - where: { user: { id: user.id } }, - relations: { user: true }, - withDeleted: true, - }); - await this.userTokenRepository.remove(userTokens); - - const albums = await this.albumRepository.find({ where: { ownerId: user.id } }); - await this.albumRepository.remove(albums); - - await this.apiKeyRepository.delete({ userId: user.id }); - await this.assetRepository.delete({ ownerId: user.id }); - await this.userRepository.remove(user); - } catch (error: any) { - this.logger.error(`Failed to remove user`); - this.logger.error(error, error?.stack); - throw error; - } - } -} diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 3512990902..93a43f64c9 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -1,25 +1,22 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { AssetEntity } from '@app/infra'; -import { IVideoConversionProcessor, JobName, QueueName, SystemConfigService } from '@app/domain'; +import { IAssetJob, IAssetRepository, JobName, QueueName, SystemConfigService } from '@app/domain'; import { Process, Processor } from '@nestjs/bull'; -import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bull'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import { existsSync, mkdirSync } from 'fs'; -import { Repository } from 'typeorm'; @Processor(QueueName.VIDEO_CONVERSION) export class VideoTranscodeProcessor { readonly logger = new Logger(VideoTranscodeProcessor.name); constructor( - @InjectRepository(AssetEntity) - private assetRepository: Repository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, private systemConfigService: SystemConfigService, ) {} @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) - async videoConversion(job: Job) { + async videoConversion(job: Job) { const { asset } = job.data; const basePath = APP_UPLOAD_LOCATION; const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`; @@ -93,7 +90,7 @@ export class VideoTranscodeProcessor { }) .on('end', async () => { this.logger.log(`Converting Success ${asset.id}`); - await this.assetRepository.update({ id: asset.id }, { encodedVideoPath: savedEncodedPath }); + await this.assetRepository.save({ id: asset.id, encodedVideoPath: savedEncodedPath }); resolve(); }) .run(); diff --git a/server/libs/common/src/utils/asset-utils.ts b/server/libs/common/src/utils/asset-utils.ts index e8cee3588f..5ec65bfe2f 100644 --- a/server/libs/common/src/utils/asset-utils.ts +++ b/server/libs/common/src/utils/asset-utils.ts @@ -1,4 +1,4 @@ -import { AssetEntity } from '@app/infra'; +import { AssetEntity } from '@app/infra/db/entities'; import { AssetResponseDto } from '@app/domain'; import fs from 'fs'; diff --git a/server/libs/common/src/utils/index.ts b/server/libs/common/src/utils/index.ts index 44ea662e16..35d65fa7e1 100644 --- a/server/libs/common/src/utils/index.ts +++ b/server/libs/common/src/utils/index.ts @@ -2,7 +2,6 @@ import { LogLevel } from '@nestjs/common'; export * from './time-utils'; export * from './asset-utils'; -export * from './user-utils'; export function getLogLevels() { const LOG_LEVELS: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error']; diff --git a/server/libs/common/src/utils/user-utils.spec.ts b/server/libs/common/src/utils/user-utils.spec.ts deleted file mode 100644 index c3aa242a22..0000000000 --- a/server/libs/common/src/utils/user-utils.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -// create unit test for user utils - -import { UserEntity } from '@app/infra'; -import { userUtils } from './user-utils'; - -describe('User Utilities', () => { - describe('checkIsReadyForDeletion', () => { - it('check that user is not ready to be deleted', () => { - const result = userUtils.isReadyForDeletion({ deletedAt: new Date() } as UserEntity); - expect(result).toBeFalsy(); - }); - - it('check that user is ready to be deleted', () => { - const aWeekAgo = new Date(new Date().getTime() - 8 * 86400000); - const result = userUtils.isReadyForDeletion({ deletedAt: aWeekAgo } as UserEntity); - expect(result).toBeTruthy(); - }); - }); -}); diff --git a/server/libs/common/src/utils/user-utils.ts b/server/libs/common/src/utils/user-utils.ts deleted file mode 100644 index d0a3c2e48a..0000000000 --- a/server/libs/common/src/utils/user-utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { UserEntity } from '@app/infra'; - -function createUserUtils() { - const isReadyForDeletion = (user: UserEntity): boolean => { - if (user.deletedAt == null) return false; - const millisecondsInDay = 86400000; - // get this number (7 days) from some configuration perhaps ? - const millisecondsDeleteWait = millisecondsInDay * 7; - - const millisecondsSinceDelete = new Date().getTime() - (Date.parse(user.deletedAt.toString()) ?? 0); - return millisecondsSinceDelete >= millisecondsDeleteWait; - }; - return { isReadyForDeletion }; -} - -export const userUtils = createUserUtils(); diff --git a/server/libs/domain/src/album/album.repository.ts b/server/libs/domain/src/album/album.repository.ts new file mode 100644 index 0000000000..92137b13d8 --- /dev/null +++ b/server/libs/domain/src/album/album.repository.ts @@ -0,0 +1,5 @@ +export const IAlbumRepository = 'IAlbumRepository'; + +export interface IAlbumRepository { + deleteAll(userId: string): Promise; +} diff --git a/server/libs/domain/src/album/index.ts b/server/libs/domain/src/album/index.ts index 0b415ca923..1231926aa2 100644 --- a/server/libs/domain/src/album/index.ts +++ b/server/libs/domain/src/album/index.ts @@ -1 +1,2 @@ +export * from './album.repository'; export * from './response-dto'; diff --git a/server/libs/domain/src/api-key/api-key.repository.ts b/server/libs/domain/src/api-key/api-key.repository.ts index 9eb9897fc5..2bcff0b20b 100644 --- a/server/libs/domain/src/api-key/api-key.repository.ts +++ b/server/libs/domain/src/api-key/api-key.repository.ts @@ -6,6 +6,7 @@ export interface IKeyRepository { create(dto: Partial): Promise; update(userId: string, id: string, dto: Partial): Promise; delete(userId: string, id: string): Promise; + deleteAll(userId: string): Promise; /** * Includes the hashed `key` for verification * @param id diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts new file mode 100644 index 0000000000..67ae308a44 --- /dev/null +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -0,0 +1,10 @@ +import { AssetEntity, AssetType } from '@app/infra/db/entities'; + +export const IAssetRepository = 'IAssetRepository'; + +export interface IAssetRepository { + deleteAll(ownerId: string): Promise; + getAll(): Promise; + save(asset: Partial): Promise; + findLivePhotoMatch(livePhotoCID: string, type: AssetType, otherAssetId: string): Promise; +} diff --git a/server/libs/domain/src/asset/asset.service.spec.ts b/server/libs/domain/src/asset/asset.service.spec.ts new file mode 100644 index 0000000000..75f26eae27 --- /dev/null +++ b/server/libs/domain/src/asset/asset.service.spec.ts @@ -0,0 +1,45 @@ +import { AssetEntity, AssetType } from '@app/infra/db/entities'; +import { newJobRepositoryMock } from '../../test'; +import { AssetService } from '../asset'; +import { IJobRepository, JobName } from '../job'; + +describe(AssetService.name, () => { + let sut: AssetService; + let jobMock: jest.Mocked; + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + beforeEach(async () => { + jobMock = newJobRepositoryMock(); + sut = new AssetService(jobMock); + }); + + describe(`handle asset upload`, () => { + it('should process an uploaded video', async () => { + const data = { asset: { type: AssetType.VIDEO } as AssetEntity, fileName: 'video.mp4' }; + + await expect(sut.handleAssetUpload(data)).resolves.toBeUndefined(); + + expect(jobMock.queue).toHaveBeenCalledTimes(3); + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }], + [{ name: JobName.VIDEO_CONVERSION, data }], + [{ name: JobName.EXTRACT_VIDEO_METADATA, data }], + ]); + }); + + it('should process an uploaded image', async () => { + const data = { asset: { type: AssetType.IMAGE } as AssetEntity, fileName: 'image.jpg' }; + + await sut.handleAssetUpload(data); + + expect(jobMock.queue).toHaveBeenCalledTimes(2); + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }], + [{ name: JobName.EXIF_EXTRACTION, data }], + ]); + }); + }); +}); diff --git a/server/libs/domain/src/asset/asset.service.ts b/server/libs/domain/src/asset/asset.service.ts new file mode 100644 index 0000000000..023fb960d9 --- /dev/null +++ b/server/libs/domain/src/asset/asset.service.ts @@ -0,0 +1,18 @@ +import { AssetType } from '@app/infra/db/entities'; +import { Inject } from '@nestjs/common'; +import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; + +export class AssetService { + constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {} + + async handleAssetUpload(data: IAssetUploadedJob) { + await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data }); + + if (data.asset.type == AssetType.VIDEO) { + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data }); + await this.jobRepository.queue({ name: JobName.EXTRACT_VIDEO_METADATA, data }); + } else { + await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data }); + } + } +} diff --git a/server/libs/domain/src/asset/index.ts b/server/libs/domain/src/asset/index.ts index 0b415ca923..aa429787d7 100644 --- a/server/libs/domain/src/asset/index.ts +++ b/server/libs/domain/src/asset/index.ts @@ -1 +1,3 @@ +export * from './asset.repository'; +export * from './asset.service'; export * from './response-dto'; diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index 2d874f485d..a8f858da72 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -42,18 +42,6 @@ const fixtures = { const CLIENT_IP = '127.0.0.1'; -jest.mock('@nestjs/common', () => ({ - ...jest.requireActual('@nestjs/common'), - Logger: jest.fn().mockReturnValue({ - verbose: jest.fn(), - debug: jest.fn(), - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }), -})); - describe('AuthService', () => { let sut: AuthService; let cryptoMock: jest.Mocked; @@ -208,6 +196,17 @@ describe('AuthService', () => { redirectUri: '/auth/login?autoLaunch=0', }); }); + + it('should delete the access token', async () => { + const authUser = { id: '123', accessTokenId: 'token123' } as AuthUserDto; + + await expect(sut.logout(authUser, AuthType.PASSWORD)).resolves.toEqual({ + successful: true, + redirectUri: '/auth/login?autoLaunch=0', + }); + + expect(userTokenMock.delete).toHaveBeenCalledWith('token123'); + }); }); describe('adminSignUp', () => { diff --git a/server/libs/domain/src/communication/communication.repository.ts b/server/libs/domain/src/communication/communication.repository.ts new file mode 100644 index 0000000000..1295214468 --- /dev/null +++ b/server/libs/domain/src/communication/communication.repository.ts @@ -0,0 +1,9 @@ +export const ICommunicationRepository = 'ICommunicationRepository'; + +export enum CommunicationEvent { + UPLOAD_SUCCESS = 'on_upload_success', +} + +export interface ICommunicationRepository { + send(event: CommunicationEvent, userId: string, data: any): void; +} diff --git a/server/libs/domain/src/communication/index.ts b/server/libs/domain/src/communication/index.ts new file mode 100644 index 0000000000..5339082c20 --- /dev/null +++ b/server/libs/domain/src/communication/index.ts @@ -0,0 +1 @@ +export * from './communication.repository'; diff --git a/server/libs/domain/src/device-info/device-info.service.spec.ts b/server/libs/domain/src/device-info/device-info.service.spec.ts index 784b6682a8..a131861da3 100644 --- a/server/libs/domain/src/device-info/device-info.service.spec.ts +++ b/server/libs/domain/src/device-info/device-info.service.spec.ts @@ -1,4 +1,4 @@ -import { DeviceInfoEntity, DeviceType } from '@app/infra'; +import { DeviceInfoEntity, DeviceType } from '@app/infra/db/entities'; import { authStub, newDeviceInfoRepositoryMock } from '../../test'; import { IDeviceInfoRepository } from './device-info.repository'; import { DeviceInfoService } from './device-info.service'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 273e1a60b3..d3faad973d 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -1,19 +1,27 @@ import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common'; import { APIKeyService } from './api-key'; +import { AssetService } from './asset'; import { AuthService } from './auth'; import { DeviceInfoService } from './device-info'; -import { JobService } from './job'; +import { MediaService } from './media'; import { OAuthService } from './oauth'; import { ShareService } from './share'; +import { SmartInfoService } from './smart-info'; +import { StorageService } from './storage'; +import { StorageTemplateService } from './storage-template'; import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config'; import { UserService } from './user'; const providers: Provider[] = [ + AssetService, APIKeyService, AuthService, DeviceInfoService, - JobService, + MediaService, OAuthService, + SmartInfoService, + StorageService, + StorageTemplateService, SystemConfigService, UserService, ShareService, diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 99456733ed..93768f6825 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -2,13 +2,17 @@ export * from './album'; export * from './api-key'; export * from './asset'; export * from './auth'; +export * from './communication'; export * from './crypto'; export * from './device-info'; export * from './domain.module'; export * from './job'; +export * from './media'; export * from './oauth'; export * from './share'; +export * from './smart-info'; export * from './storage'; +export * from './storage-template'; export * from './system-config'; export * from './tag'; export * from './user'; diff --git a/server/libs/domain/src/job/index.ts b/server/libs/domain/src/job/index.ts index 205aa3d20d..5721a508cf 100644 --- a/server/libs/domain/src/job/index.ts +++ b/server/libs/domain/src/job/index.ts @@ -1,4 +1,3 @@ -export * from './interfaces'; export * from './job.constants'; +export * from './job.interface'; export * from './job.repository'; -export * from './job.service'; diff --git a/server/libs/domain/src/job/interfaces/asset-uploaded.interface.ts b/server/libs/domain/src/job/interfaces/asset-uploaded.interface.ts deleted file mode 100644 index e4bf11685d..0000000000 --- a/server/libs/domain/src/job/interfaces/asset-uploaded.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AssetEntity } from '@app/infra/db/entities'; - -export interface IAssetUploadedJob { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; - - /** - * Original file name - */ - fileName: string; -} diff --git a/server/libs/domain/src/job/interfaces/background-task.interface.ts b/server/libs/domain/src/job/interfaces/background-task.interface.ts deleted file mode 100644 index bd3a611535..0000000000 --- a/server/libs/domain/src/job/interfaces/background-task.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AssetEntity } from '@app/infra/db/entities'; - -export interface IDeleteFileOnDiskJob { - assets: AssetEntity[]; -} diff --git a/server/libs/domain/src/job/interfaces/index.ts b/server/libs/domain/src/job/interfaces/index.ts deleted file mode 100644 index e08f93822a..0000000000 --- a/server/libs/domain/src/job/interfaces/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './asset-uploaded.interface'; -export * from './background-task.interface'; -export * from './machine-learning.interface'; -export * from './metadata-extraction.interface'; -export * from './thumbnail-generation.interface'; -export * from './user-deletion.interface'; -export * from './video-transcode.interface'; diff --git a/server/libs/domain/src/job/interfaces/machine-learning.interface.ts b/server/libs/domain/src/job/interfaces/machine-learning.interface.ts deleted file mode 100644 index 2f68d9d478..0000000000 --- a/server/libs/domain/src/job/interfaces/machine-learning.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AssetEntity } from '@app/infra/db/entities'; - -export interface IMachineLearningJob { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; -} diff --git a/server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts b/server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts deleted file mode 100644 index c64678a6ab..0000000000 --- a/server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { AssetEntity } from '@app/infra/db/entities'; - -export interface IExifExtractionProcessor { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; - - /** - * Original file name - */ - fileName: string; -} - -export interface IVideoLengthExtractionProcessor { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; - - /** - * Original file name - */ - fileName: string; -} - -export interface IReverseGeocodingProcessor { - assetId: string; - latitude: number; - longitude: number; -} - -export type IMetadataExtractionJob = - | IExifExtractionProcessor - | IVideoLengthExtractionProcessor - | IReverseGeocodingProcessor; diff --git a/server/libs/domain/src/job/interfaces/thumbnail-generation.interface.ts b/server/libs/domain/src/job/interfaces/thumbnail-generation.interface.ts deleted file mode 100644 index 0bac83d34b..0000000000 --- a/server/libs/domain/src/job/interfaces/thumbnail-generation.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AssetEntity } from '@app/infra/db/entities'; - -export interface JpegGeneratorProcessor { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; -} - -export interface WebpGeneratorProcessor { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; -} - -export type IThumbnailGenerationJob = JpegGeneratorProcessor | WebpGeneratorProcessor; diff --git a/server/libs/domain/src/job/interfaces/user-deletion.interface.ts b/server/libs/domain/src/job/interfaces/user-deletion.interface.ts deleted file mode 100644 index 4b4720b0c9..0000000000 --- a/server/libs/domain/src/job/interfaces/user-deletion.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { UserEntity } from '@app/infra/db/entities'; - -export interface IUserDeletionJob { - /** - * The user entity that was saved in the database - */ - user: UserEntity; -} diff --git a/server/libs/domain/src/job/interfaces/video-transcode.interface.ts b/server/libs/domain/src/job/interfaces/video-transcode.interface.ts deleted file mode 100644 index 325a491e9f..0000000000 --- a/server/libs/domain/src/job/interfaces/video-transcode.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AssetEntity } from '@app/infra/db/entities'; - -export interface IVideoConversionProcessor { - /** - * The Asset entity that was saved in the database - */ - asset: AssetEntity; -} - -export type IVideoTranscodeJob = IVideoConversionProcessor; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index 50b9e2f90b..13939e17f2 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -2,11 +2,9 @@ export enum QueueName { THUMBNAIL_GENERATION = 'thumbnail-generation-queue', METADATA_EXTRACTION = 'metadata-extraction-queue', VIDEO_CONVERSION = 'video-conversion-queue', - ASSET_UPLOADED = 'asset-uploaded-queue', MACHINE_LEARNING = 'machine-learning-queue', - USER_DELETION = 'user-deletion-queue', - CONFIG = 'config-queue', BACKGROUND_TASK = 'background-task', + STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', } export enum JobName { @@ -18,9 +16,10 @@ export enum JobName { EXTRACT_VIDEO_METADATA = 'extract-video-metadata', REVERSE_GEOCODING = 'reverse-geocoding', USER_DELETION = 'user-deletion', - TEMPLATE_MIGRATION = 'template-migration', - CONFIG_CHANGE = 'config-change', + USER_DELETE_CHECK = 'user-delete-check', + STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', + SYSTEM_CONFIG_CHANGE = 'system-config-change', OBJECT_DETECTION = 'detect-object', IMAGE_TAGGING = 'tag-image', - DELETE_FILE_ON_DISK = 'delete-file-on-disk', + DELETE_FILES = 'delete-files', } diff --git a/server/libs/domain/src/job/job.interface.ts b/server/libs/domain/src/job/job.interface.ts new file mode 100644 index 0000000000..e52b1d879e --- /dev/null +++ b/server/libs/domain/src/job/job.interface.ts @@ -0,0 +1,26 @@ +import { AssetEntity, UserEntity } from '@app/infra/db/entities'; + +export interface IAssetJob { + asset: AssetEntity; +} + +export interface IAssetUploadedJob { + asset: AssetEntity; + fileName: string; +} + +export interface IDeleteFilesJob { + files: Array; +} + +export interface IUserDeletionJob { + user: UserEntity; +} + +export interface IReverseGeocodingJob { + assetId: string; + latitude: number; + longitude: number; +} + +export type IMetadataExtractionJob = IAssetUploadedJob | IReverseGeocodingJob; diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index 2ad4e33f30..f06c791a3a 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -1,16 +1,5 @@ -import { - IAssetUploadedJob, - IDeleteFileOnDiskJob, - IExifExtractionProcessor, - IMachineLearningJob, - IVideoConversionProcessor, - IReverseGeocodingProcessor, - IUserDeletionJob, - IVideoLengthExtractionProcessor, - JpegGeneratorProcessor, - WebpGeneratorProcessor, -} from './interfaces'; import { JobName, QueueName } from './job.constants'; +import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface'; export interface JobCounts { active: number; @@ -20,30 +9,27 @@ export interface JobCounts { waiting: number; } -export interface Job { - data: T; -} - export type JobItem = | { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob } - | { name: JobName.VIDEO_CONVERSION; data: IVideoConversionProcessor } - | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: JpegGeneratorProcessor } - | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: WebpGeneratorProcessor } - | { name: JobName.EXIF_EXTRACTION; data: IExifExtractionProcessor } - | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingProcessor } + | { name: JobName.VIDEO_CONVERSION; data: IAssetJob } + | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob } + | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob } + | { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob } + | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob } + | { name: JobName.USER_DELETE_CHECK } | { name: JobName.USER_DELETION; data: IUserDeletionJob } - | { name: JobName.TEMPLATE_MIGRATION } - | { name: JobName.CONFIG_CHANGE } - | { name: JobName.EXTRACT_VIDEO_METADATA; data: IVideoLengthExtractionProcessor } - | { name: JobName.OBJECT_DETECTION; data: IMachineLearningJob } - | { name: JobName.IMAGE_TAGGING; data: IMachineLearningJob } - | { name: JobName.DELETE_FILE_ON_DISK; data: IDeleteFileOnDiskJob }; + | { name: JobName.STORAGE_TEMPLATE_MIGRATION } + | { name: JobName.SYSTEM_CONFIG_CHANGE } + | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob } + | { name: JobName.OBJECT_DETECTION; data: IAssetJob } + | { name: JobName.IMAGE_TAGGING; data: IAssetJob } + | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }; export const IJobRepository = 'IJobRepository'; export interface IJobRepository { + queue(item: JobItem): Promise; empty(name: QueueName): Promise; - add(item: JobItem): Promise; isActive(name: QueueName): Promise; getJobCounts(name: QueueName): Promise; } diff --git a/server/libs/domain/src/job/job.service.spec.ts b/server/libs/domain/src/job/job.service.spec.ts deleted file mode 100644 index 8b7b47ef80..0000000000 --- a/server/libs/domain/src/job/job.service.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AssetEntity, AssetType } from '@app/infra/db/entities'; -import { newJobRepositoryMock } from '../../test'; -import { IAssetUploadedJob } from './interfaces'; -import { JobName } from './job.constants'; -import { IJobRepository, Job } from './job.repository'; -import { JobService } from './job.service'; - -const jobStub = { - upload: { - video: Object.freeze>({ - data: { asset: { type: AssetType.VIDEO } as AssetEntity, fileName: 'video.mp4' }, - }), - image: Object.freeze>({ - data: { asset: { type: AssetType.IMAGE } as AssetEntity, fileName: 'image.jpg' }, - }), - }, -}; - -describe(JobService.name, () => { - let sut: JobService; - let jobMock: jest.Mocked; - - it('should work', () => { - expect(sut).toBeDefined(); - }); - - beforeEach(async () => { - jobMock = newJobRepositoryMock(); - sut = new JobService(jobMock); - }); - - describe('handleUploadedAsset', () => { - it('should process a video', async () => { - await expect(sut.handleUploadedAsset(jobStub.upload.video)).resolves.toBeUndefined(); - - expect(jobMock.add).toHaveBeenCalledTimes(3); - expect(jobMock.add.mock.calls).toEqual([ - [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset: { type: AssetType.VIDEO } } }], - [{ name: JobName.VIDEO_CONVERSION, data: { asset: { type: AssetType.VIDEO } } }], - [{ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset: { type: AssetType.VIDEO }, fileName: 'video.mp4' } }], - ]); - }); - - it('should process an image', async () => { - await sut.handleUploadedAsset(jobStub.upload.image); - - expect(jobMock.add).toHaveBeenCalledTimes(2); - expect(jobMock.add.mock.calls).toEqual([ - [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset: { type: AssetType.IMAGE } } }], - [{ name: JobName.EXIF_EXTRACTION, data: { asset: { type: AssetType.IMAGE }, fileName: 'image.jpg' } }], - ]); - }); - }); -}); diff --git a/server/libs/domain/src/job/job.service.ts b/server/libs/domain/src/job/job.service.ts deleted file mode 100644 index be142090b5..0000000000 --- a/server/libs/domain/src/job/job.service.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { IAssetUploadedJob } from './interfaces'; -import { JobUploadCore } from './job.upload.core'; -import { IJobRepository, Job } from './job.repository'; - -@Injectable() -export class JobService { - private uploadCore: JobUploadCore; - - constructor(@Inject(IJobRepository) repository: IJobRepository) { - this.uploadCore = new JobUploadCore(repository); - } - - async handleUploadedAsset(job: Job) { - await this.uploadCore.handleAsset(job); - } -} diff --git a/server/libs/domain/src/job/job.upload.core.ts b/server/libs/domain/src/job/job.upload.core.ts deleted file mode 100644 index 79efe93b18..0000000000 --- a/server/libs/domain/src/job/job.upload.core.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { AssetType } from '@app/infra/db/entities'; -import { IAssetUploadedJob } from './interfaces'; -import { JobName } from './job.constants'; -import { IJobRepository, Job } from './job.repository'; - -export class JobUploadCore { - constructor(private repository: IJobRepository) {} - - /** - * Post processing uploaded asset to perform the following function - * 1. Generate JPEG Thumbnail - * 2. Generate Webp Thumbnail - * 3. EXIF extractor - * 4. Reverse Geocoding - * - * @param job asset-uploaded - */ - async handleAsset(job: Job) { - const { asset, fileName } = job.data; - - await this.repository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); - - // Video Conversion - if (asset.type == AssetType.VIDEO) { - await this.repository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); - await this.repository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName } }); - } else { - // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet - await this.repository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName } }); - } - } -} diff --git a/server/libs/domain/src/media/index.ts b/server/libs/domain/src/media/index.ts new file mode 100644 index 0000000000..5ab3597d70 --- /dev/null +++ b/server/libs/domain/src/media/index.ts @@ -0,0 +1,2 @@ +export * from './media.repository'; +export * from './media.service'; diff --git a/server/libs/domain/src/media/media.repository.ts b/server/libs/domain/src/media/media.repository.ts new file mode 100644 index 0000000000..83a4777ea3 --- /dev/null +++ b/server/libs/domain/src/media/media.repository.ts @@ -0,0 +1,12 @@ +export const IMediaRepository = 'IMediaRepository'; + +export interface ResizeOptions { + size: number; + format: 'webp' | 'jpeg'; +} + +export interface IMediaRepository { + resize(input: string, output: string, options: ResizeOptions): Promise; + extractVideoThumbnail(input: string, output: string): Promise; + extractThumbnailFromExif(input: string, output: string): Promise; +} diff --git a/server/libs/domain/src/media/media.service.ts b/server/libs/domain/src/media/media.service.ts new file mode 100644 index 0000000000..32ec126dec --- /dev/null +++ b/server/libs/domain/src/media/media.service.ts @@ -0,0 +1,99 @@ +import { APP_UPLOAD_LOCATION } from '@app/common'; +import { AssetType } from '@app/infra/db/entities'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { join } from 'path'; +import sanitize from 'sanitize-filename'; +import { IAssetRepository, mapAsset } from '../asset'; +import { CommunicationEvent, ICommunicationRepository } from '../communication'; +import { IAssetJob, IJobRepository, JobName } from '../job'; +import { IStorageRepository } from '../storage'; +import { IMediaRepository } from './media.repository'; + +@Injectable() +export class MediaService { + private logger = new Logger(MediaService.name); + + constructor( + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IMediaRepository) private mediaRepository: IMediaRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) {} + + async handleGenerateJpegThumbnail(data: IAssetJob): Promise { + const { asset } = data; + + const basePath = APP_UPLOAD_LOCATION; + const sanitizedDeviceId = sanitize(String(asset.deviceId)); + const resizePath = join(basePath, asset.ownerId, 'thumb', sanitizedDeviceId); + const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); + + this.storageRepository.mkdirSync(resizePath); + + if (asset.type == AssetType.IMAGE) { + try { + await this.mediaRepository + .resize(asset.originalPath, jpegThumbnailPath, { size: 1440, format: 'jpeg' }) + .catch(() => { + this.logger.warn( + 'Failed to generate jpeg thumbnail for asset: ' + + asset.id + + ' using sharp, failing over to exiftool-vendored', + ); + return this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath); + }); + await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath }); + } catch (error: any) { + this.logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id, error.stack); + } + + // Update resize path to send to generate webp queue + asset.resizePath = jpegThumbnailPath; + + await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); + await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } }); + await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } }); + + this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); + } + + if (asset.type == AssetType.VIDEO) { + try { + this.logger.log('Start Generating Video Thumbnail'); + await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath); + this.logger.log(`Generating Video Thumbnail Success ${asset.id}`); + + await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath }); + + // Update resize path to send to generate webp queue + asset.resizePath = jpegThumbnailPath; + + await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); + await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } }); + await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } }); + + this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); + } catch (error: any) { + this.logger.error(`Cannot Generate Video Thumbnail: ${asset.id}`, error?.stack); + } + } + } + + async handleGenerateWepbThumbnail(data: IAssetJob): Promise { + const { asset } = data; + + if (!asset.resizePath) { + return; + } + + const webpPath = asset.resizePath.replace('jpeg', 'webp'); + + try { + await this.mediaRepository.resize(asset.resizePath, webpPath, { size: 250, format: 'webp' }); + await this.assetRepository.save({ id: asset.id, webpPath: webpPath }); + } catch (error: any) { + this.logger.error('Failed to generate webp thumbnail for asset: ' + asset.id, error.stack); + } + } +} diff --git a/server/libs/domain/src/oauth/oauth.service.spec.ts b/server/libs/domain/src/oauth/oauth.service.spec.ts index 36e6a86584..d752a34207 100644 --- a/server/libs/domain/src/oauth/oauth.service.spec.ts +++ b/server/libs/domain/src/oauth/oauth.service.spec.ts @@ -21,18 +21,6 @@ import { newUserTokenRepositoryMock } from '../../test/user-token.repository.moc const email = 'user@immich.com'; const sub = 'my-auth-user-sub'; -jest.mock('@nestjs/common', () => ({ - ...jest.requireActual('@nestjs/common'), - Logger: jest.fn().mockReturnValue({ - verbose: jest.fn(), - debug: jest.fn(), - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }), -})); - describe('OAuthService', () => { let sut: OAuthService; let userMock: jest.Mocked; diff --git a/server/libs/domain/src/smart-info/index.ts b/server/libs/domain/src/smart-info/index.ts new file mode 100644 index 0000000000..86c2d1eccd --- /dev/null +++ b/server/libs/domain/src/smart-info/index.ts @@ -0,0 +1,3 @@ +export * from './machine-learning.interface'; +export * from './smart-info.repository'; +export * from './smart-info.service'; diff --git a/server/libs/domain/src/smart-info/machine-learning.interface.ts b/server/libs/domain/src/smart-info/machine-learning.interface.ts new file mode 100644 index 0000000000..a175890814 --- /dev/null +++ b/server/libs/domain/src/smart-info/machine-learning.interface.ts @@ -0,0 +1,10 @@ +export const IMachineLearningRepository = 'IMachineLearningRepository'; + +export interface MachineLearningInput { + thumbnailPath: string; +} + +export interface IMachineLearningRepository { + tagImage(input: MachineLearningInput): Promise; + detectObjects(input: MachineLearningInput): Promise; +} diff --git a/server/libs/domain/src/smart-info/smart-info.repository.ts b/server/libs/domain/src/smart-info/smart-info.repository.ts new file mode 100644 index 0000000000..3fb04d4263 --- /dev/null +++ b/server/libs/domain/src/smart-info/smart-info.repository.ts @@ -0,0 +1,7 @@ +import { SmartInfoEntity } from '@app/infra/db/entities'; + +export const ISmartInfoRepository = 'ISmartInfoRepository'; + +export interface ISmartInfoRepository { + upsert(info: Partial): Promise; +} diff --git a/server/libs/domain/src/smart-info/smart-info.service.spec.ts b/server/libs/domain/src/smart-info/smart-info.service.spec.ts new file mode 100644 index 0000000000..7d859ba8b4 --- /dev/null +++ b/server/libs/domain/src/smart-info/smart-info.service.spec.ts @@ -0,0 +1,102 @@ +import { AssetEntity } from '@app/infra/db/entities'; +import { newMachineLearningRepositoryMock, newSmartInfoRepositoryMock } from '../../test'; +import { IMachineLearningRepository } from './machine-learning.interface'; +import { ISmartInfoRepository } from './smart-info.repository'; +import { SmartInfoService } from './smart-info.service'; + +const asset = { + id: 'asset-1', + resizePath: 'path/to/resize.ext', +} as AssetEntity; + +describe(SmartInfoService.name, () => { + let sut: SmartInfoService; + let smartMock: jest.Mocked; + let machineMock: jest.Mocked; + + beforeEach(async () => { + smartMock = newSmartInfoRepositoryMock(); + machineMock = newMachineLearningRepositoryMock(); + sut = new SmartInfoService(smartMock, machineMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('handleTagImage', () => { + it('should skip assets without a resize path', async () => { + await sut.handleTagImage({ asset: { resizePath: '' } as AssetEntity }); + + expect(smartMock.upsert).not.toHaveBeenCalled(); + expect(machineMock.tagImage).not.toHaveBeenCalled(); + }); + + it('should save the returned tags', async () => { + machineMock.tagImage.mockResolvedValue(['tag1', 'tag2', 'tag3']); + + await sut.handleTagImage({ asset }); + + expect(machineMock.tagImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); + expect(smartMock.upsert).toHaveBeenCalledWith({ + assetId: 'asset-1', + tags: ['tag1', 'tag2', 'tag3'], + }); + }); + + it('should handle an error with the machine learning pipeline', async () => { + machineMock.tagImage.mockRejectedValue(new Error('Unable to read thumbnail')); + + await sut.handleTagImage({ asset }); + + expect(smartMock.upsert).not.toHaveBeenCalled(); + }); + + it('should no update the smart info if no tags were returned', async () => { + machineMock.tagImage.mockResolvedValue([]); + + await sut.handleTagImage({ asset }); + + expect(machineMock.tagImage).toHaveBeenCalled(); + expect(smartMock.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('handleDetectObjects', () => { + it('should skip assets without a resize path', async () => { + await sut.handleDetectObjects({ asset: { resizePath: '' } as AssetEntity }); + + expect(smartMock.upsert).not.toHaveBeenCalled(); + expect(machineMock.detectObjects).not.toHaveBeenCalled(); + }); + + it('should save the returned objects', async () => { + machineMock.detectObjects.mockResolvedValue(['obj1', 'obj2', 'obj3']); + + await sut.handleDetectObjects({ asset }); + + expect(machineMock.detectObjects).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); + expect(smartMock.upsert).toHaveBeenCalledWith({ + assetId: 'asset-1', + objects: ['obj1', 'obj2', 'obj3'], + }); + }); + + it('should handle an error with the machine learning pipeline', async () => { + machineMock.detectObjects.mockRejectedValue(new Error('Unable to read thumbnail')); + + await sut.handleDetectObjects({ asset }); + + expect(smartMock.upsert).not.toHaveBeenCalled(); + }); + + it('should no update the smart info if no objects were returned', async () => { + machineMock.detectObjects.mockResolvedValue([]); + + await sut.handleDetectObjects({ asset }); + + expect(machineMock.detectObjects).toHaveBeenCalled(); + expect(smartMock.upsert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/libs/domain/src/smart-info/smart-info.service.ts b/server/libs/domain/src/smart-info/smart-info.service.ts new file mode 100644 index 0000000000..f3185e58f6 --- /dev/null +++ b/server/libs/domain/src/smart-info/smart-info.service.ts @@ -0,0 +1,49 @@ +import { MACHINE_LEARNING_ENABLED } from '@app/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { IAssetJob } from '../job'; +import { IMachineLearningRepository } from './machine-learning.interface'; +import { ISmartInfoRepository } from './smart-info.repository'; + +@Injectable() +export class SmartInfoService { + private logger = new Logger(SmartInfoService.name); + + constructor( + @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository, + @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, + ) {} + + async handleTagImage(data: IAssetJob) { + const { asset } = data; + + if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { + return; + } + + try { + const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath }); + if (tags.length > 0) { + await this.repository.upsert({ assetId: asset.id, tags }); + } + } catch (error: any) { + this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack); + } + } + + async handleDetectObjects(data: IAssetJob) { + const { asset } = data; + + if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { + return; + } + + try { + const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath }); + if (objects.length > 0) { + await this.repository.upsert({ assetId: asset.id, objects }); + } + } catch (error: any) { + this.logger.error(`Unable run object detection pipeline: ${asset.id}`, error?.stack); + } + } +} diff --git a/server/libs/domain/src/storage-template/index.ts b/server/libs/domain/src/storage-template/index.ts new file mode 100644 index 0000000000..a62fc5faad --- /dev/null +++ b/server/libs/domain/src/storage-template/index.ts @@ -0,0 +1,2 @@ +export * from './storage-template.core'; +export * from './storage-template.service'; diff --git a/server/libs/storage/src/storage.service.ts b/server/libs/domain/src/storage-template/storage-template.core.ts similarity index 62% rename from server/libs/storage/src/storage.service.ts rename to server/libs/domain/src/storage-template/storage-template.core.ts index ed4ddb3acd..8769ed1054 100644 --- a/server/libs/storage/src/storage.service.ts +++ b/server/libs/domain/src/storage-template/storage-template.core.ts @@ -1,18 +1,7 @@ import { APP_UPLOAD_LOCATION } from '@app/common'; -import { AssetEntity, AssetType, SystemConfig } from '@app/infra'; -import { SystemConfigService, INITIAL_SYSTEM_CONFIG } from '@app/domain'; -import { Inject, Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import fsPromise from 'fs/promises'; -import handlebar from 'handlebars'; -import * as luxon from 'luxon'; -import mv from 'mv'; -import { constants } from 'node:fs'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import sanitize from 'sanitize-filename'; -import { Repository } from 'typeorm'; import { + IStorageRepository, + ISystemConfigRepository, supportedDayTokens, supportedHourTokens, supportedMinuteTokens, @@ -20,32 +9,31 @@ import { supportedSecondTokens, supportedYearTokens, } from '@app/domain'; +import { AssetEntity, AssetType, SystemConfig } from '@app/infra/db/entities'; +import { Logger } from '@nestjs/common'; +import handlebar from 'handlebars'; +import * as luxon from 'luxon'; +import path from 'node:path'; +import sanitize from 'sanitize-filename'; +import { SystemConfigCore } from '../system-config/system-config.core'; -const moveFile = promisify(mv); - -@Injectable() -export class StorageService { - private readonly logger = new Logger(StorageService.name); - +export class StorageTemplateCore { + private logger = new Logger(StorageTemplateCore.name); + private configCore: SystemConfigCore; private storageTemplate: HandlebarsTemplateDelegate; constructor( - @InjectRepository(AssetEntity) - private assetRepository: Repository, - private systemConfigService: SystemConfigService, - @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, + configRepository: ISystemConfigRepository, + config: SystemConfig, + private storageRepository: IStorageRepository, ) { this.storageTemplate = this.compile(config.storageTemplate.template); - - this.systemConfigService.addValidator((config) => this.validateConfig(config)); - - this.systemConfigService.config$.subscribe((config) => { - this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); - this.storageTemplate = this.compile(config.storageTemplate.template); - }); + this.configCore = new SystemConfigCore(configRepository); + this.configCore.addValidator((config) => this.validateConfig(config)); + this.configCore.config$.subscribe((config) => this.onConfig(config)); } - public async moveAsset(asset: AssetEntity, filename: string): Promise { + public async getTemplatePath(asset: AssetEntity, filename: string): Promise { try { const source = asset.originalPath; const ext = path.extname(source).split('.').pop() as string; @@ -57,11 +45,11 @@ export class StorageService { if (!fullPath.startsWith(rootPath)) { this.logger.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`); - return asset; + return source; } if (source === destination) { - return asset; + return source; } /** @@ -82,14 +70,14 @@ export class StorageService { const diff = source.replace(fullPath, '').replace(`.${ext}`, ''); const hasDuplicationAnnotation = /^\+\d+$/.test(diff); if (hasDuplicationAnnotation) { - return asset; + return source; } } let duplicateCount = 0; while (true) { - const exists = await this.checkFileExist(destination); + const exists = await this.storageRepository.checkFileExists(destination); if (!exists) { break; } @@ -98,26 +86,10 @@ export class StorageService { destination = `${fullPath}+${duplicateCount}.${ext}`; } - await this.safeMove(source, destination); - - asset.originalPath = destination; - return await this.assetRepository.save(asset); + return destination; } catch (error: any) { - this.logger.error(error); - return asset; - } - } - - private safeMove(source: string, destination: string): Promise { - return moveFile(source, destination, { mkdirp: true, clobber: false }); - } - - private async checkFileExist(path: string): Promise { - try { - await fsPromise.access(path, constants.F_OK); - return true; - } catch (_) { - return false; + this.logger.error(`Unable to get template path for ${filename}`, error); + return asset.originalPath; } } @@ -145,6 +117,11 @@ export class StorageService { } } + private onConfig(config: SystemConfig) { + this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); + this.storageTemplate = this.compile(config.storageTemplate.template); + } + private compile(template: string) { return handlebar.compile(template, { knownHelpers: undefined, @@ -182,27 +159,4 @@ export class StorageService { return template(substitutions); } - - public async removeEmptyDirectories(directory: string) { - // lstat does not follow symlinks (in contrast to stat) - const fileStats = await fsPromise.lstat(directory); - if (!fileStats.isDirectory()) { - return; - } - let fileNames = await fsPromise.readdir(directory); - if (fileNames.length > 0) { - const recursiveRemovalPromises = fileNames.map((fileName) => - this.removeEmptyDirectories(path.join(directory, fileName)), - ); - await Promise.all(recursiveRemovalPromises); - - // re-evaluate fileNames; after deleting subdirectory - // we may have parent directory empty now - fileNames = await fsPromise.readdir(directory); - } - - if (fileNames.length === 0) { - await fsPromise.rmdir(directory); - } - } } diff --git a/server/libs/domain/src/storage-template/storage-template.service.spec.ts b/server/libs/domain/src/storage-template/storage-template.service.spec.ts new file mode 100644 index 0000000000..6401b7ab46 --- /dev/null +++ b/server/libs/domain/src/storage-template/storage-template.service.spec.ts @@ -0,0 +1,149 @@ +import { when } from 'jest-when'; +import { + assetEntityStub, + newAssetRepositoryMock, + newStorageRepositoryMock, + newSystemConfigRepositoryMock, + systemConfigStub, +} from '../../test'; +import { IAssetRepository } from '../asset'; +import { StorageTemplateService } from '../storage-template'; +import { IStorageRepository } from '../storage/storage.repository'; +import { ISystemConfigRepository } from '../system-config'; + +describe(StorageTemplateService.name, () => { + let sut: StorageTemplateService; + let assetMock: jest.Mocked; + let configMock: jest.Mocked; + let storageMock: jest.Mocked; + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + beforeEach(async () => { + assetMock = newAssetRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); + storageMock = newStorageRepositoryMock(); + sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock); + }); + + describe('handle template migration', () => { + it('should handle no assets', async () => { + assetMock.getAll.mockResolvedValue([]); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + }); + + it('should handle an asset with a duplicate destination', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + assetMock.save.mockResolvedValue(assetEntityStub.image); + + when(storageMock.checkFileExists) + .calledWith('upload/user-id/2023/2023-02-23/asset-id.ext') + .mockResolvedValue(true); + + when(storageMock.checkFileExists) + .calledWith('upload/user-id/2023/2023-02-23/asset-id+1.ext') + .mockResolvedValue(false); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetEntityStub.image.id, + originalPath: 'upload/user-id/2023/2023-02-23/asset-id+1.ext', + }); + }); + + it('should skip when an asset already matches the template', async () => { + assetMock.getAll.mockResolvedValue([ + { + ...assetEntityStub.image, + originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext', + }, + ]); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(storageMock.moveFile).not.toHaveBeenCalled(); + expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(assetMock.save).not.toHaveBeenCalled(); + }); + + it('should skip when an asset is probably a duplicate', async () => { + assetMock.getAll.mockResolvedValue([ + { + ...assetEntityStub.image, + originalPath: 'upload/user-id/2023/2023-02-23/asset-id+1.ext', + }, + ]); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(storageMock.moveFile).not.toHaveBeenCalled(); + expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2); + expect(assetMock.save).not.toHaveBeenCalled(); + }); + + it('should move an asset', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + assetMock.save.mockResolvedValue(assetEntityStub.image); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(storageMock.moveFile).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/user-id/2023/2023-02-23/asset-id.ext', + ); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetEntityStub.image.id, + originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext', + }); + }); + + it('should not update the database if the move fails', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + storageMock.moveFile.mockRejectedValue(new Error('Read only system')); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(storageMock.moveFile).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/user-id/2023/2023-02-23/asset-id.ext', + ); + expect(assetMock.save).not.toHaveBeenCalled(); + }); + + it('should move the asset back if the database fails', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + assetMock.save.mockRejectedValue('Connection Error!'); + + await sut.handleTemplateMigration(); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetEntityStub.image.id, + originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext', + }); + expect(storageMock.moveFile.mock.calls).toEqual([ + ['/original/path.ext', 'upload/user-id/2023/2023-02-23/asset-id.ext'], + ['upload/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'], + ]); + }); + }); + + it('should handle an error', async () => { + assetMock.getAll.mockResolvedValue([]); + storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem')); + + await sut.handleTemplateMigration(); + }); +}); diff --git a/server/libs/domain/src/storage-template/storage-template.service.ts b/server/libs/domain/src/storage-template/storage-template.service.ts new file mode 100644 index 0000000000..96e03e12a5 --- /dev/null +++ b/server/libs/domain/src/storage-template/storage-template.service.ts @@ -0,0 +1,73 @@ +import { APP_UPLOAD_LOCATION } from '@app/common'; +import { AssetEntity, SystemConfig } from '@app/infra/db/entities'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { IAssetRepository } from '../asset/asset.repository'; +import { IStorageRepository } from '../storage/storage.repository'; +import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; +import { StorageTemplateCore } from './storage-template.core'; + +@Injectable() +export class StorageTemplateService { + private logger = new Logger(StorageTemplateService.name); + private core: StorageTemplateCore; + + constructor( + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) { + this.core = new StorageTemplateCore(configRepository, config, storageRepository); + } + + async handleTemplateMigration() { + try { + console.time('migrating-time'); + const assets = await this.assetRepository.getAll(); + + const livePhotoMap: Record = {}; + + for (const asset of assets) { + if (asset.livePhotoVideoId) { + livePhotoMap[asset.livePhotoVideoId] = asset; + } + } + + for (const asset of assets) { + const livePhotoParentAsset = livePhotoMap[asset.id]; + // TODO: remove livePhoto specific stuff once upload is fixed + const filename = asset.exifInfo?.imageName || livePhotoParentAsset?.exifInfo?.imageName || asset.id; + await this.moveAsset(asset, filename); + } + + this.logger.debug('Cleaning up empty directories...'); + await this.storageRepository.removeEmptyDirs(APP_UPLOAD_LOCATION); + } catch (error: any) { + this.logger.error('Error running template migration', error); + } finally { + console.timeEnd('migrating-time'); + } + } + + // TODO: use asset core (once in domain) + async moveAsset(asset: AssetEntity, originalName: string) { + const destination = await this.core.getTemplatePath(asset, originalName); + if (asset.originalPath !== destination) { + const source = asset.originalPath; + + try { + await this.storageRepository.moveFile(asset.originalPath, destination); + try { + await this.assetRepository.save({ id: asset.id, originalPath: destination }); + asset.originalPath = destination; + } catch (error: any) { + this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack); + await this.storageRepository.moveFile(destination, source); + } + } catch (error: any) { + this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination }); + } + } + return asset; + } +} diff --git a/server/libs/domain/src/storage/index.ts b/server/libs/domain/src/storage/index.ts index b6109ead5e..72e76e38ac 100644 --- a/server/libs/domain/src/storage/index.ts +++ b/server/libs/domain/src/storage/index.ts @@ -1 +1,2 @@ export * from './storage.repository'; +export * from './storage.service'; diff --git a/server/libs/domain/src/storage/storage.repository.ts b/server/libs/domain/src/storage/storage.repository.ts index 53d452f318..2af1120b7c 100644 --- a/server/libs/domain/src/storage/storage.repository.ts +++ b/server/libs/domain/src/storage/storage.repository.ts @@ -10,4 +10,10 @@ export const IStorageRepository = 'IStorageRepository'; export interface IStorageRepository { createReadStream(filepath: string, mimeType: string): Promise; + unlink(filepath: string): Promise; + unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; + removeEmptyDirs(folder: string): Promise; + moveFile(source: string, target: string): Promise; + checkFileExists(filepath: string): Promise; + mkdirSync(filepath: string): void; } diff --git a/server/libs/domain/src/storage/storage.service.spec.ts b/server/libs/domain/src/storage/storage.service.spec.ts new file mode 100644 index 0000000000..7a2c58c5dc --- /dev/null +++ b/server/libs/domain/src/storage/storage.service.spec.ts @@ -0,0 +1,39 @@ +import { newStorageRepositoryMock } from '../../test'; +import { IStorageRepository } from '../storage'; +import { StorageService } from './storage.service'; + +describe(StorageService.name, () => { + let sut: StorageService; + let storageMock: jest.Mocked; + + beforeEach(async () => { + storageMock = newStorageRepositoryMock(); + sut = new StorageService(storageMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('handleDeleteFiles', () => { + it('should handle null values', async () => { + await sut.handleDeleteFiles({ files: [undefined, null] }); + + expect(storageMock.unlink).not.toHaveBeenCalled(); + }); + + it('should handle an error removing a file', async () => { + storageMock.unlink.mockRejectedValue(new Error('something-went-wrong')); + + await sut.handleDeleteFiles({ files: ['path/to/something'] }); + + expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + }); + + it('should remove the file', async () => { + await sut.handleDeleteFiles({ files: ['path/to/something'] }); + + expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something'); + }); + }); +}); diff --git a/server/libs/domain/src/storage/storage.service.ts b/server/libs/domain/src/storage/storage.service.ts new file mode 100644 index 0000000000..149f0b8e96 --- /dev/null +++ b/server/libs/domain/src/storage/storage.service.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { IDeleteFilesJob } from '../job'; +import { IStorageRepository } from './storage.repository'; + +@Injectable() +export class StorageService { + private logger = new Logger(StorageService.name); + + constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {} + + async handleDeleteFiles(job: IDeleteFilesJob) { + const { files } = job; + + for (const file of files) { + if (!file) { + continue; + } + + try { + await this.storageRepository.unlink(file); + } catch (error: any) { + this.logger.warn('Unable to remove file from disk', error); + } + } + } +} diff --git a/server/libs/domain/src/system-config/system-config.service.spec.ts b/server/libs/domain/src/system-config/system-config.service.spec.ts index c7880d8796..9eeb0befde 100644 --- a/server/libs/domain/src/system-config/system-config.service.spec.ts +++ b/server/libs/domain/src/system-config/system-config.service.spec.ts @@ -127,7 +127,7 @@ describe(SystemConfigService.name, () => { await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(configMock.saveAll).toHaveBeenCalledWith(updates); - expect(jobMock.add).toHaveBeenCalledWith({ name: JobName.CONFIG_CHANGE }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SYSTEM_CONFIG_CHANGE }); }); it('should throw an error if the config is not valid', async () => { diff --git a/server/libs/domain/src/system-config/system-config.service.ts b/server/libs/domain/src/system-config/system-config.service.ts index a03602ed5f..2134a6f628 100644 --- a/server/libs/domain/src/system-config/system-config.service.ts +++ b/server/libs/domain/src/system-config/system-config.service.ts @@ -19,7 +19,7 @@ export class SystemConfigService { private core: SystemConfigCore; constructor( @Inject(ISystemConfigRepository) repository: ISystemConfigRepository, - @Inject(IJobRepository) private queue: IJobRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.core = new SystemConfigCore(repository); } @@ -40,7 +40,7 @@ export class SystemConfigService { async updateConfig(dto: SystemConfigDto): Promise { const config = await this.core.updateConfig(dto); - await this.queue.add({ name: JobName.CONFIG_CHANGE }); + await this.jobRepository.queue({ name: JobName.SYSTEM_CONFIG_CHANGE }); return mapConfig(config); } diff --git a/server/libs/domain/src/user-token/user-token.repository.ts b/server/libs/domain/src/user-token/user-token.repository.ts index a084d22e8f..b2309e9feb 100644 --- a/server/libs/domain/src/user-token/user-token.repository.ts +++ b/server/libs/domain/src/user-token/user-token.repository.ts @@ -5,5 +5,6 @@ export const IUserTokenRepository = 'IUserTokenRepository'; export interface IUserTokenRepository { create(dto: Partial): Promise; delete(userToken: string): Promise; + deleteAll(userId: string): Promise; get(userToken: string): Promise; } diff --git a/server/libs/domain/src/user/user.repository.ts b/server/libs/domain/src/user/user.repository.ts index fab52eeeba..33669e583a 100644 --- a/server/libs/domain/src/user/user.repository.ts +++ b/server/libs/domain/src/user/user.repository.ts @@ -11,9 +11,10 @@ export interface IUserRepository { getAdmin(): Promise; getByEmail(email: string, withPassword?: boolean): Promise; getByOAuthId(oauthId: string): Promise; + getDeletedUsers(): Promise; getList(filter?: UserListFilter): Promise; create(user: Partial): Promise; update(id: string, user: Partial): Promise; - delete(user: UserEntity): Promise; + delete(user: UserEntity, hard?: boolean): Promise; restore(user: UserEntity): Promise; } diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts index 938e324f5b..d0ab69fbf7 100644 --- a/server/libs/domain/src/user/user.service.spec.ts +++ b/server/libs/domain/src/user/user.service.spec.ts @@ -1,13 +1,34 @@ -import { IUserRepository } from './user.repository'; import { UserEntity } from '@app/infra/db/entities'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { when } from 'jest-when'; -import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test'; +import { + newAlbumRepositoryMock, + newAssetRepositoryMock, + newCryptoRepositoryMock, + newJobRepositoryMock, + newKeyRepositoryMock, + newStorageRepositoryMock, + newUserRepositoryMock, + newUserTokenRepositoryMock, +} from '../../test'; +import { IAlbumRepository } from '../album'; +import { IKeyRepository } from '../api-key'; +import { IAssetRepository } from '../asset'; import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto'; +import { IJobRepository, JobName } from '../job'; +import { IStorageRepository } from '../storage'; +import { IUserTokenRepository } from '../user-token'; import { UpdateUserDto } from './dto/update-user.dto'; +import { IUserRepository } from './user.repository'; import { UserService } from './user.service'; +const makeDeletedAt = (daysAgo: number) => { + const deletedAt = new Date(); + deletedAt.setDate(deletedAt.getDate() - daysAgo); + return deletedAt; +}; + const adminUserAuth: AuthUserDto = Object.freeze({ id: 'admin_id', email: 'admin@test.com', @@ -83,10 +104,35 @@ describe(UserService.name, () => { let userRepositoryMock: jest.Mocked; let cryptoRepositoryMock: jest.Mocked; + let albumMock: jest.Mocked; + let assetMock: jest.Mocked; + let jobMock: jest.Mocked; + let keyMock: jest.Mocked; + let storageMock: jest.Mocked; + let tokenMock: jest.Mocked; + beforeEach(async () => { userRepositoryMock = newUserRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); - sut = new UserService(userRepositoryMock, cryptoRepositoryMock); + + albumMock = newAlbumRepositoryMock(); + assetMock = newAssetRepositoryMock(); + jobMock = newJobRepositoryMock(); + keyMock = newKeyRepositoryMock(); + storageMock = newStorageRepositoryMock(); + tokenMock = newUserTokenRepositoryMock(); + userRepositoryMock = newUserRepositoryMock(); + + sut = new UserService( + userRepositoryMock, + cryptoRepositoryMock, + albumMock, + assetMock, + jobMock, + keyMock, + storageMock, + tokenMock, + ); when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); @@ -374,4 +420,64 @@ describe(UserService.name, () => { expect(update.password).toBeDefined(); }); }); + + describe('handleUserDeleteCheck', () => { + it('should skip users not ready for deletion', async () => { + userRepositoryMock.getDeletedUsers.mockResolvedValue([ + {}, + { deletedAt: undefined }, + { deletedAt: null }, + { deletedAt: makeDeletedAt(5) }, + ] as UserEntity[]); + + await sut.handleUserDeleteCheck(); + + expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should queue user ready for deletion', async () => { + const user = { deletedAt: makeDeletedAt(10) }; + userRepositoryMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + + await sut.handleUserDeleteCheck(); + + expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } }); + }); + }); + + describe('handleUserDelete', () => { + it('should skip users not ready for deletion', async () => { + const user = { deletedAt: makeDeletedAt(5) } as UserEntity; + + await sut.handleUserDelete({ user }); + + expect(storageMock.unlinkDir).not.toHaveBeenCalled(); + expect(userRepositoryMock.delete).not.toHaveBeenCalled(); + }); + + it('should delete the user and associated assets', async () => { + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; + + await sut.handleUserDelete({ user }); + + expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/deleted-user', { force: true, recursive: true }); + expect(tokenMock.deleteAll).toHaveBeenCalledWith(user.id); + expect(keyMock.deleteAll).toHaveBeenCalledWith(user.id); + expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id); + expect(assetMock.deleteAll).toHaveBeenCalledWith(user.id); + expect(userRepositoryMock.delete).toHaveBeenCalledWith(user, true); + }); + + it('should handle an error', async () => { + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; + + storageMock.unlinkDir.mockRejectedValue(new Error('Read only filesystem')); + + await sut.handleUserDelete({ user }); + + expect(userRepositoryMock.delete).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/libs/domain/src/user/user.service.ts b/server/libs/domain/src/user/user.service.ts index dc804c6444..f2a8aee29a 100644 --- a/server/libs/domain/src/user/user.service.ts +++ b/server/libs/domain/src/user/user.service.ts @@ -1,26 +1,43 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { UserEntity } from '@app/infra/db/entities'; +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { ReadStream } from 'fs'; +import { join } from 'path'; +import { APP_UPLOAD_LOCATION } from '@app/common'; +import { IAlbumRepository } from '../album/album.repository'; +import { IKeyRepository } from '../api-key/api-key.repository'; +import { IAssetRepository } from '../asset/asset.repository'; import { AuthUserDto } from '../auth'; -import { ICryptoRepository } from '../crypto'; -import { IUserRepository } from '../user'; -import { CreateUserDto } from './dto/create-user.dto'; -import { UpdateUserDto } from './dto/update-user.dto'; -import { UserCountDto } from './dto/user-count.dto'; +import { ICryptoRepository } from '../crypto/crypto.repository'; +import { IJobRepository, IUserDeletionJob, JobName } from '../job'; +import { IStorageRepository } from '../storage/storage.repository'; +import { IUserTokenRepository } from '../user-token/user-token.repository'; +import { IUserRepository } from '../user/user.repository'; +import { CreateUserDto, UpdateUserDto, UserCountDto } from './dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse, -} from './response-dto/create-profile-image-response.dto'; -import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto'; -import { mapUser, UserResponseDto } from './response-dto/user-response.dto'; + mapUser, + mapUserCountResponse, + UserCountResponseDto, + UserResponseDto, +} from './response-dto'; import { UserCore } from './user.core'; @Injectable() export class UserService { + private logger = new Logger(UserService.name); private userCore: UserCore; constructor( - @Inject(IUserRepository) userRepository: IUserRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IKeyRepository) private keyRepository: IKeyRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IUserTokenRepository) private tokenRepository: IUserTokenRepository, ) { this.userCore = new UserCore(userRepository, cryptoRepository); } @@ -123,4 +140,53 @@ export class UserService { return { admin, password, provided: !!providedPassword }; } + + async handleUserDeleteCheck() { + const users = await this.userRepository.getDeletedUsers(); + for (const user of users) { + if (this.isReadyForDeletion(user)) { + await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { user } }); + } + } + } + + async handleUserDelete(data: IUserDeletionJob) { + const { user } = data; + + // just for extra protection here + if (!this.isReadyForDeletion(user)) { + this.logger.warn(`Skipped user that was not ready for deletion: id=${user.id}`); + return; + } + + this.logger.log(`Deleting user: ${user.id}`); + + try { + const userAssetDir = join(APP_UPLOAD_LOCATION, user.id); + this.logger.warn(`Removing user from filesystem: ${userAssetDir}`); + await this.storageRepository.unlinkDir(userAssetDir, { recursive: true, force: true }); + + this.logger.warn(`Removing user from database: ${user.id}`); + + await this.tokenRepository.deleteAll(user.id); + await this.keyRepository.deleteAll(user.id); + await this.albumRepository.deleteAll(user.id); + await this.assetRepository.deleteAll(user.id); + await this.userRepository.delete(user, true); + } catch (error: any) { + this.logger.error(`Failed to remove user`, error, { id: user.id }); + } + } + + private isReadyForDeletion(user: UserEntity): boolean { + if (!user.deletedAt) { + return false; + } + + const msInDay = 86400000; + const msDeleteWait = msInDay * 7; + const msSinceDelete = new Date().getTime() - (Date.parse(user.deletedAt.toString()) || 0); + + return msSinceDelete >= msDeleteWait; + } } diff --git a/server/libs/domain/test/album.repository.mock.ts b/server/libs/domain/test/album.repository.mock.ts new file mode 100644 index 0000000000..a240524aed --- /dev/null +++ b/server/libs/domain/test/album.repository.mock.ts @@ -0,0 +1,7 @@ +import { IAlbumRepository } from '../src'; + +export const newAlbumRepositoryMock = (): jest.Mocked => { + return { + deleteAll: jest.fn(), + }; +}; diff --git a/server/libs/domain/test/api-key.repository.mock.ts b/server/libs/domain/test/api-key.repository.mock.ts index dbf8405fc1..8bf5b690fd 100644 --- a/server/libs/domain/test/api-key.repository.mock.ts +++ b/server/libs/domain/test/api-key.repository.mock.ts @@ -5,6 +5,7 @@ export const newKeyRepositoryMock = (): jest.Mocked => { create: jest.fn(), update: jest.fn(), delete: jest.fn(), + deleteAll: jest.fn(), getKey: jest.fn(), getById: jest.fn(), getByUserId: jest.fn(), diff --git a/server/libs/domain/test/asset.repository.mock.ts b/server/libs/domain/test/asset.repository.mock.ts new file mode 100644 index 0000000000..d65477d126 --- /dev/null +++ b/server/libs/domain/test/asset.repository.mock.ts @@ -0,0 +1,10 @@ +import { IAssetRepository } from '../src'; + +export const newAssetRepositoryMock = (): jest.Mocked => { + return { + getAll: jest.fn(), + deleteAll: jest.fn(), + save: jest.fn(), + findLivePhotoMatch: jest.fn(), + }; +}; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 0d6ae37410..dc972337e4 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -91,22 +91,37 @@ export const userEntityStub = { }), }; +export const fileStub = { + livePhotoStill: Object.freeze({ + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + }), + livePhotoMotion: Object.freeze({ + originalPath: 'fake_path/asset_1.mp4', + mimeType: 'image/jpeg', + checksum: Buffer.from('live photo file hash', 'utf8'), + originalName: 'asset_1.mp4', + }), +}; + export const assetEntityStub = { image: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', - fileModifiedAt: today.toISOString(), - fileCreatedAt: today.toISOString(), + fileModifiedAt: '2023-02-23T05:06:29.716Z', + fileCreatedAt: '2023-02-23T05:06:29.716Z', owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: '/original/path', + originalPath: '/original/path.ext', resizePath: null, type: AssetType.IMAGE, webpPath: null, encodedVideoPath: null, - createdAt: today.toISOString(), - updatedAt: today.toISOString(), + createdAt: '2023-02-23T05:06:29.716Z', + updatedAt: '2023-02-23T05:06:29.716Z', mimeType: null, isFavorite: true, duration: null, @@ -116,6 +131,26 @@ export const assetEntityStub = { tags: [], sharedLinks: [], }), + livePhotoMotionAsset: Object.freeze({ + id: 'live-photo-motion-asset', + originalPath: fileStub.livePhotoMotion.originalPath, + ownerId: authStub.user1.id, + type: AssetType.VIDEO, + isVisible: false, + fileModifiedAt: '2022-06-19T23:41:36.910Z', + fileCreatedAt: '2022-06-19T23:41:36.910Z', + } as AssetEntity), + + livePhotoStillAsset: Object.freeze({ + id: 'live-photo-still-asset', + originalPath: fileStub.livePhotoStill.originalPath, + ownerId: authStub.user1.id, + type: AssetType.IMAGE, + livePhotoVideoId: 'live-photo-motion-asset', + isVisible: true, + fileModifiedAt: '2022-06-19T23:41:36.910Z', + fileCreatedAt: '2022-06-19T23:41:36.910Z', + } as AssetEntity), }; const assetInfo: ExifResponseDto = { diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index 94c4f27503..d2ba46e725 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -1,9 +1,13 @@ +export * from './album.repository.mock'; export * from './api-key.repository.mock'; +export * from './asset.repository.mock'; export * from './crypto.repository.mock'; export * from './device-info.repository.mock'; export * from './fixtures'; export * from './job.repository.mock'; +export * from './machine-learning.repository.mock'; export * from './shared-link.repository.mock'; +export * from './smart-info.repository.mock'; export * from './storage.repository.mock'; export * from './system-config.repository.mock'; export * from './user-token.repository.mock'; diff --git a/server/libs/domain/test/job.repository.mock.ts b/server/libs/domain/test/job.repository.mock.ts index 623f10fbf1..22c47b2dad 100644 --- a/server/libs/domain/test/job.repository.mock.ts +++ b/server/libs/domain/test/job.repository.mock.ts @@ -3,7 +3,7 @@ import { IJobRepository } from '../src'; export const newJobRepositoryMock = (): jest.Mocked => { return { empty: jest.fn(), - add: jest.fn().mockImplementation(() => Promise.resolve()), + queue: jest.fn().mockImplementation(() => Promise.resolve()), isActive: jest.fn(), getJobCounts: jest.fn(), }; diff --git a/server/libs/domain/test/machine-learning.repository.mock.ts b/server/libs/domain/test/machine-learning.repository.mock.ts new file mode 100644 index 0000000000..7c7c2b2553 --- /dev/null +++ b/server/libs/domain/test/machine-learning.repository.mock.ts @@ -0,0 +1,8 @@ +import { IMachineLearningRepository } from '../src'; + +export const newMachineLearningRepositoryMock = (): jest.Mocked => { + return { + tagImage: jest.fn(), + detectObjects: jest.fn(), + }; +}; diff --git a/server/libs/domain/test/setup.ts b/server/libs/domain/test/setup.ts new file mode 100644 index 0000000000..0a2bd92b64 --- /dev/null +++ b/server/libs/domain/test/setup.ts @@ -0,0 +1,11 @@ +jest.mock('@nestjs/common', () => ({ + ...jest.requireActual('@nestjs/common'), + Logger: jest.fn().mockReturnValue({ + verbose: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); diff --git a/server/libs/domain/test/smart-info.repository.mock.ts b/server/libs/domain/test/smart-info.repository.mock.ts new file mode 100644 index 0000000000..9eb5bc9986 --- /dev/null +++ b/server/libs/domain/test/smart-info.repository.mock.ts @@ -0,0 +1,7 @@ +import { ISmartInfoRepository } from '../src'; + +export const newSmartInfoRepositoryMock = (): jest.Mocked => { + return { + upsert: jest.fn(), + }; +}; diff --git a/server/libs/domain/test/storage.repository.mock.ts b/server/libs/domain/test/storage.repository.mock.ts index 6cad338699..7661d6449f 100644 --- a/server/libs/domain/test/storage.repository.mock.ts +++ b/server/libs/domain/test/storage.repository.mock.ts @@ -3,5 +3,11 @@ import { IStorageRepository } from '../src'; export const newStorageRepositoryMock = (): jest.Mocked => { return { createReadStream: jest.fn(), + unlink: jest.fn(), + unlinkDir: jest.fn(), + removeEmptyDirs: jest.fn(), + moveFile: jest.fn(), + checkFileExists: jest.fn(), + mkdirSync: jest.fn(), }; }; diff --git a/server/libs/domain/test/user-token.repository.mock.ts b/server/libs/domain/test/user-token.repository.mock.ts index 593f96c0f4..7f8e449659 100644 --- a/server/libs/domain/test/user-token.repository.mock.ts +++ b/server/libs/domain/test/user-token.repository.mock.ts @@ -4,6 +4,7 @@ export const newUserTokenRepositoryMock = (): jest.Mocked return { create: jest.fn(), delete: jest.fn(), + deleteAll: jest.fn(), get: jest.fn(), }; }; diff --git a/server/libs/domain/test/user.repository.mock.ts b/server/libs/domain/test/user.repository.mock.ts index 89d04ae263..0938250366 100644 --- a/server/libs/domain/test/user.repository.mock.ts +++ b/server/libs/domain/test/user.repository.mock.ts @@ -10,6 +10,7 @@ export const newUserRepositoryMock = (): jest.Mocked => { create: jest.fn(), update: jest.fn(), delete: jest.fn(), + getDeletedUsers: jest.fn(), restore: jest.fn(), }; }; diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/libs/infra/src/communication/communication.gateway.ts similarity index 100% rename from server/apps/immich/src/api-v1/communication/communication.gateway.ts rename to server/libs/infra/src/communication/communication.gateway.ts diff --git a/server/libs/infra/src/communication/communication.repository.ts b/server/libs/infra/src/communication/communication.repository.ts new file mode 100644 index 0000000000..e0320076cc --- /dev/null +++ b/server/libs/infra/src/communication/communication.repository.ts @@ -0,0 +1,12 @@ +import { CommunicationEvent } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { CommunicationGateway } from './communication.gateway'; + +@Injectable() +export class CommunicationRepository { + constructor(private ws: CommunicationGateway) {} + + send(event: CommunicationEvent, userId: string, data: any) { + this.ws.server.to(userId).emit(event, JSON.stringify(data)); + } +} diff --git a/server/libs/infra/src/communication/index.ts b/server/libs/infra/src/communication/index.ts new file mode 100644 index 0000000000..88ed49b907 --- /dev/null +++ b/server/libs/infra/src/communication/index.ts @@ -0,0 +1,2 @@ +export * from './communication.gateway'; +export * from './communication.repository'; diff --git a/server/libs/infra/src/db/repository/album.repository.ts b/server/libs/infra/src/db/repository/album.repository.ts new file mode 100644 index 0000000000..1615d99577 --- /dev/null +++ b/server/libs/infra/src/db/repository/album.repository.ts @@ -0,0 +1,14 @@ +import { IAlbumRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AlbumEntity } from '../entities'; + +@Injectable() +export class AlbumRepository implements IAlbumRepository { + constructor(@InjectRepository(AlbumEntity) private repository: Repository) {} + + async deleteAll(userId: string): Promise { + await this.repository.delete({ ownerId: userId }); + } +} diff --git a/server/libs/infra/src/db/repository/api-key.repository.ts b/server/libs/infra/src/db/repository/api-key.repository.ts index 2484b0d561..095c7ae632 100644 --- a/server/libs/infra/src/db/repository/api-key.repository.ts +++ b/server/libs/infra/src/db/repository/api-key.repository.ts @@ -21,6 +21,10 @@ export class APIKeyRepository implements IKeyRepository { await this.repository.delete({ userId, id }); } + async deleteAll(userId: string): Promise { + await this.repository.delete({ userId }); + } + getKey(hashedToken: string): Promise { return this.repository.findOne({ select: { diff --git a/server/libs/infra/src/db/repository/asset.repository.ts b/server/libs/infra/src/db/repository/asset.repository.ts new file mode 100644 index 0000000000..7be96048af --- /dev/null +++ b/server/libs/infra/src/db/repository/asset.repository.ts @@ -0,0 +1,38 @@ +import { IAssetRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Not, Repository } from 'typeorm'; +import { AssetEntity, AssetType } from '../entities'; + +@Injectable() +export class AssetRepository implements IAssetRepository { + constructor(@InjectRepository(AssetEntity) private repository: Repository) {} + + async deleteAll(ownerId: string): Promise { + await this.repository.delete({ ownerId }); + } + + async getAll(): Promise { + return this.repository.find({ relations: { exifInfo: true } }); + } + + async save(asset: Partial): Promise { + const { id } = await this.repository.save(asset); + return this.repository.findOneOrFail({ where: { id } }); + } + + findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { + return this.repository.findOne({ + where: { + id: Not(otherAssetId), + type, + exifInfo: { + livePhotoCID, + }, + }, + relations: { + exifInfo: true, + }, + }); + } +} diff --git a/server/libs/infra/src/db/repository/index.ts b/server/libs/infra/src/db/repository/index.ts index e8d73b0833..1696cc6d97 100644 --- a/server/libs/infra/src/db/repository/index.ts +++ b/server/libs/infra/src/db/repository/index.ts @@ -1,6 +1,9 @@ +export * from './album.repository'; export * from './api-key.repository'; +export * from './asset.repository'; export * from './device-info.repository'; export * from './shared-link.repository'; +export * from './smart-info.repository'; export * from './system-config.repository'; export * from './user-token.repository'; export * from './user.repository'; diff --git a/server/libs/infra/src/db/repository/smart-info.repository.ts b/server/libs/infra/src/db/repository/smart-info.repository.ts new file mode 100644 index 0000000000..8932efaf05 --- /dev/null +++ b/server/libs/infra/src/db/repository/smart-info.repository.ts @@ -0,0 +1,14 @@ +import { ISmartInfoRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SmartInfoEntity } from '../entities'; + +@Injectable() +export class SmartInfoRepository implements ISmartInfoRepository { + constructor(@InjectRepository(SmartInfoEntity) private repository: Repository) {} + + async upsert(info: Partial): Promise { + await this.repository.upsert(info, { conflictPaths: ['assetId'] }); + } +} diff --git a/server/libs/infra/src/db/repository/user-token.repository.ts b/server/libs/infra/src/db/repository/user-token.repository.ts index eca4ded9d4..0f42dfa2a7 100644 --- a/server/libs/infra/src/db/repository/user-token.repository.ts +++ b/server/libs/infra/src/db/repository/user-token.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { UserTokenEntity } from '@app/infra/db/entities/user-token.entity'; +import { UserTokenEntity } from '../entities/user-token.entity'; import { IUserTokenRepository } from '@app/domain/user-token'; @Injectable() @@ -22,4 +22,8 @@ export class UserTokenRepository implements IUserTokenRepository { async delete(id: string): Promise { await this.userTokenRepository.delete(id); } + + async deleteAll(userId: string): Promise { + await this.userTokenRepository.delete({ user: { id: userId } }); + } } diff --git a/server/libs/infra/src/db/repository/user.repository.ts b/server/libs/infra/src/db/repository/user.repository.ts index d0f9b99052..fadeba99e6 100644 --- a/server/libs/infra/src/db/repository/user.repository.ts +++ b/server/libs/infra/src/db/repository/user.repository.ts @@ -2,7 +2,7 @@ import { UserEntity } from '../entities'; import { IUserRepository, UserListFilter } from '@app/domain'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Not, Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; @Injectable() export class UserRepository implements IUserRepository { @@ -33,6 +33,10 @@ export class UserRepository implements IUserRepository { return this.userRepository.findOne({ where: { oauthId } }); } + async getDeletedUsers(): Promise { + return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); + } + async getList({ excludeId }: UserListFilter = {}): Promise { if (!excludeId) { return this.userRepository.find(); // TODO: this should also be ordered the same as below @@ -61,8 +65,12 @@ export class UserRepository implements IUserRepository { return updatedUser; } - async delete(user: UserEntity): Promise { - return this.userRepository.softRemove(user); + async delete(user: UserEntity, hard?: boolean): Promise { + if (hard) { + return this.userRepository.remove(user); + } else { + return this.userRepository.softRemove(user); + } } async restore(user: UserEntity): Promise { diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index 720ca9d116..a783fe45b4 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -1,43 +1,65 @@ import { + IAlbumRepository, + IAssetRepository, + ICommunicationRepository, ICryptoRepository, IDeviceInfoRepository, IJobRepository, IKeyRepository, + IMachineLearningRepository, + IMediaRepository, ISharedLinkRepository, + ISmartInfoRepository, IStorageRepository, ISystemConfigRepository, IUserRepository, + IUserTokenRepository, QueueName, } from '@app/domain'; -import { IUserTokenRepository } from '@app/domain/user-token'; -import { UserTokenRepository } from '@app/infra/db/repository/user-token.repository'; import { BullModule } from '@nestjs/bull'; import { Global, Module, Provider } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CryptoRepository } from './auth/crypto.repository'; +import { CommunicationGateway, CommunicationRepository } from './communication'; import { + AlbumEntity, + AlbumRepository, APIKeyEntity, APIKeyRepository, + AssetEntity, + AssetRepository, databaseConfig, DeviceInfoEntity, DeviceInfoRepository, SharedLinkEntity, SharedLinkRepository, + SmartInfoEntity, + SmartInfoRepository, SystemConfigEntity, SystemConfigRepository, UserEntity, UserRepository, UserTokenEntity, + UserTokenRepository, } from './db'; import { JobRepository } from './job'; +import { MachineLearningRepository } from './machine-learning'; +import { MediaRepository } from './media'; import { FilesystemProvider } from './storage'; const providers: Provider[] = [ + { provide: IAlbumRepository, useClass: AlbumRepository }, + { provide: IAssetRepository, useClass: AssetRepository }, + { provide: ICommunicationRepository, useClass: CommunicationRepository }, + { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDeviceInfoRepository, useClass: DeviceInfoRepository }, { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IJobRepository, useClass: JobRepository }, + { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, + { provide: IMediaRepository, useClass: MediaRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, + { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, { provide: IStorageRepository, useClass: FilesystemProvider }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: IUserRepository, useClass: UserRepository }, @@ -49,10 +71,13 @@ const providers: Provider[] = [ imports: [ TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forFeature([ + AssetEntity, + AlbumEntity, APIKeyEntity, DeviceInfoEntity, UserEntity, SharedLinkEntity, + SmartInfoEntity, SystemConfigEntity, UserTokenEntity, ]), @@ -73,18 +98,9 @@ const providers: Provider[] = [ }, }), }), - BullModule.registerQueue( - { name: QueueName.USER_DELETION }, - { name: QueueName.THUMBNAIL_GENERATION }, - { name: QueueName.ASSET_UPLOADED }, - { name: QueueName.METADATA_EXTRACTION }, - { name: QueueName.VIDEO_CONVERSION }, - { name: QueueName.MACHINE_LEARNING }, - { name: QueueName.CONFIG }, - { name: QueueName.BACKGROUND_TASK }, - ), + BullModule.registerQueue(...Object.values(QueueName).map((name) => ({ name }))), ], - providers: [...providers], + providers: [...providers, CommunicationGateway], exports: [...providers, BullModule], }) export class InfraModule {} diff --git a/server/libs/infra/src/job/job.repository.ts b/server/libs/infra/src/job/job.repository.ts index 83a7bb1043..4c791a3972 100644 --- a/server/libs/infra/src/job/job.repository.ts +++ b/server/libs/infra/src/job/job.repository.ts @@ -1,15 +1,4 @@ -import { - IAssetUploadedJob, - IJobRepository, - IMachineLearningJob, - IMetadataExtractionJob, - IUserDeletionJob, - IVideoTranscodeJob, - JobCounts, - JobItem, - JobName, - QueueName, -} from '@app/domain'; +import { IAssetJob, IJobRepository, IMetadataExtractionJob, JobCounts, JobItem, JobName, QueueName } from '@app/domain'; import { InjectQueue } from '@nestjs/bull'; import { BadRequestException, Logger } from '@nestjs/common'; import { Queue } from 'bull'; @@ -18,14 +7,12 @@ export class JobRepository implements IJobRepository { private logger = new Logger(JobRepository.name); constructor( - @InjectQueue(QueueName.ASSET_UPLOADED) private assetUploaded: Queue, @InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue, - @InjectQueue(QueueName.MACHINE_LEARNING) private machineLearning: Queue, + @InjectQueue(QueueName.MACHINE_LEARNING) private machineLearning: Queue, @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue, - @InjectQueue(QueueName.CONFIG) private storageMigration: Queue, + @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, - @InjectQueue(QueueName.USER_DELETION) private userDeletion: Queue, - @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, + @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, ) {} async isActive(name: QueueName): Promise { @@ -41,13 +28,13 @@ export class JobRepository implements IJobRepository { return this.getQueue(name).getJobCounts(); } - async add(item: JobItem): Promise { + async queue(item: JobItem): Promise { switch (item.name) { case JobName.ASSET_UPLOADED: - await this.assetUploaded.add(item.name, item.data, { jobId: item.data.asset.id }); + await this.backgroundTask.add(item.name, item.data, { jobId: item.data.asset.id }); break; - case JobName.DELETE_FILE_ON_DISK: + case JobName.DELETE_FILES: await this.backgroundTask.add(item.name, item.data); break; @@ -62,18 +49,21 @@ export class JobRepository implements IJobRepository { await this.metadataExtraction.add(item.name, item.data); break; - case JobName.TEMPLATE_MIGRATION: - case JobName.CONFIG_CHANGE: - await this.storageMigration.add(item.name, {}); - break; - case JobName.GENERATE_JPEG_THUMBNAIL: case JobName.GENERATE_WEBP_THUMBNAIL: await this.thumbnail.add(item.name, item.data); break; case JobName.USER_DELETION: - await this.userDeletion.add(item.name, item.data); + await this.backgroundTask.add(item.name, item.data); + break; + + case JobName.STORAGE_TEMPLATE_MIGRATION: + await this.storageTemplateMigration.add(item.name); + break; + + case JobName.SYSTEM_CONFIG_CHANGE: + await this.backgroundTask.add(item.name, {}); break; case JobName.VIDEO_CONVERSION: @@ -88,14 +78,14 @@ export class JobRepository implements IJobRepository { private getQueue(name: QueueName) { switch (name) { + case QueueName.STORAGE_TEMPLATE_MIGRATION: + return this.storageTemplateMigration; case QueueName.THUMBNAIL_GENERATION: return this.thumbnail; case QueueName.METADATA_EXTRACTION: return this.metadataExtraction; case QueueName.VIDEO_CONVERSION: return this.videoTranscode; - case QueueName.CONFIG: - return this.storageMigration; case QueueName.MACHINE_LEARNING: return this.machineLearning; default: diff --git a/server/libs/infra/src/machine-learning/index.ts b/server/libs/infra/src/machine-learning/index.ts new file mode 100644 index 0000000000..dde6fa72a5 --- /dev/null +++ b/server/libs/infra/src/machine-learning/index.ts @@ -0,0 +1 @@ +export * from './machine-learning.repository'; diff --git a/server/libs/infra/src/machine-learning/machine-learning.repository.ts b/server/libs/infra/src/machine-learning/machine-learning.repository.ts new file mode 100644 index 0000000000..e69e068ecc --- /dev/null +++ b/server/libs/infra/src/machine-learning/machine-learning.repository.ts @@ -0,0 +1,17 @@ +import { MACHINE_LEARNING_URL } from '@app/common'; +import { IMachineLearningRepository, MachineLearningInput } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; + +const client = axios.create({ baseURL: MACHINE_LEARNING_URL }); + +@Injectable() +export class MachineLearningRepository implements IMachineLearningRepository { + tagImage(input: MachineLearningInput): Promise { + return client.post('/image-classifier/tag-image', input).then((res) => res.data); + } + + detectObjects(input: MachineLearningInput): Promise { + return client.post('/object-detection/detect-object', input).then((res) => res.data); + } +} diff --git a/server/libs/infra/src/media/index.ts b/server/libs/infra/src/media/index.ts new file mode 100644 index 0000000000..3c1dba5e64 --- /dev/null +++ b/server/libs/infra/src/media/index.ts @@ -0,0 +1 @@ +export * from './media.repository'; diff --git a/server/libs/infra/src/media/media.repository.ts b/server/libs/infra/src/media/media.repository.ts new file mode 100644 index 0000000000..779ccbc0c6 --- /dev/null +++ b/server/libs/infra/src/media/media.repository.ts @@ -0,0 +1,37 @@ +import { IMediaRepository, ResizeOptions } from '@app/domain'; +import { exiftool } from 'exiftool-vendored'; +import ffmpeg from 'fluent-ffmpeg'; +import sharp from 'sharp'; + +export class MediaRepository implements IMediaRepository { + extractThumbnailFromExif(input: string, output: string): Promise { + return exiftool.extractThumbnail(input, output); + } + + async resize(input: string, output: string, options: ResizeOptions): Promise { + switch (options.format) { + case 'webp': + await sharp(input, { failOnError: false }).resize(250).webp().rotate().toFile(output); + return; + + case 'jpeg': + await sharp(input, { failOnError: false }) + .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) + .jpeg() + .rotate() + .toFile(output); + return; + } + } + + extractVideoThumbnail(input: string, output: string) { + return new Promise((resolve, reject) => { + ffmpeg(input) + .outputOptions(['-ss 00:00:00.000', '-frames:v 1']) + .output(output) + .on('error', reject) + .on('end', resolve) + .run(); + }); + } +} diff --git a/server/libs/infra/src/storage/filesystem.provider.ts b/server/libs/infra/src/storage/filesystem.provider.ts index ca2d847f1b..9441f58184 100644 --- a/server/libs/infra/src/storage/filesystem.provider.ts +++ b/server/libs/infra/src/storage/filesystem.provider.ts @@ -1,13 +1,15 @@ import { ImmichReadStream, IStorageRepository } from '@app/domain'; -import { constants, createReadStream, stat } from 'fs'; +import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import fs from 'fs/promises'; -import { promisify } from 'util'; +import mv from 'mv'; +import { promisify } from 'node:util'; +import path from 'path'; -const fileInfo = promisify(stat); +const moveFile = promisify(mv); export class FilesystemProvider implements IStorageRepository { async createReadStream(filepath: string, mimeType: string): Promise { - const { size } = await fileInfo(filepath); + const { size } = await fs.stat(filepath); await fs.access(filepath, constants.R_OK | constants.W_OK); return { stream: createReadStream(filepath), @@ -15,4 +17,53 @@ export class FilesystemProvider implements IStorageRepository { type: mimeType, }; } + + async moveFile(source: string, destination: string): Promise { + await moveFile(source, destination, { mkdirp: true, clobber: false }); + } + + async checkFileExists(filepath: string): Promise { + try { + await fs.access(filepath, constants.F_OK); + return true; + } catch (_) { + return false; + } + } + + async unlink(file: string) { + await fs.unlink(file); + } + + async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) { + await fs.rm(folder, options); + } + + async removeEmptyDirs(directory: string) { + this._removeEmptyDirs(directory, false); + } + + private async _removeEmptyDirs(directory: string, self: boolean) { + // lstat does not follow symlinks (in contrast to stat) + const stats = await fs.lstat(directory); + if (!stats.isDirectory()) { + return; + } + + const files = await fs.readdir(directory); + await Promise.all(files.map((file) => this._removeEmptyDirs(path.join(directory, file), true))); + + if (self) { + const updated = await fs.readdir(directory); + if (updated.length === 0) { + await fs.rmdir(directory); + } + } + } + + mkdirSync(filepath: string): void { + if (!existsSync(filepath)) { + mkdirSync(filepath, { recursive: true }); + } + } } diff --git a/server/libs/storage/src/index.ts b/server/libs/storage/src/index.ts deleted file mode 100644 index 5c65021d00..0000000000 --- a/server/libs/storage/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './storage.module'; -export * from './storage.service'; diff --git a/server/libs/storage/src/storage.module.ts b/server/libs/storage/src/storage.module.ts deleted file mode 100644 index b88795fd20..0000000000 --- a/server/libs/storage/src/storage.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AssetEntity } from '@app/infra'; -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { StorageService } from './storage.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([AssetEntity])], - providers: [StorageService], - exports: [StorageService], -}) -export class StorageModule {} diff --git a/server/libs/storage/tsconfig.lib.json b/server/libs/storage/tsconfig.lib.json deleted file mode 100644 index b99dca8010..0000000000 --- a/server/libs/storage/tsconfig.lib.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "declaration": true, - "outDir": "../../dist/libs/storage" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] -} diff --git a/server/package.json b/server/package.json index 7653f6cd09..6f3992cfd1 100644 --- a/server/package.json +++ b/server/package.json @@ -131,21 +131,24 @@ }, "collectCoverageFrom": [ "**/*.(t|j)s", - "!**/migrations/*" + "!/libs/infra/**/*" ], "coverageDirectory": "./coverage", "coverageThreshold": { "global": { - "lines": 20, - "statements": 20 + "lines": 17, + "statements": 17 }, "./libs/domain/": { "branches": 80, - "functions": 88, - "lines": 95, - "statements": 94 + "functions": 85, + "lines": 90, + "statements": 90 } }, + "setupFilesAfterEnv": [ + "/libs/domain/test/setup.ts" + ], "testEnvironment": "node", "roots": [ "/apps/", @@ -153,7 +156,6 @@ ], "moduleNameMapper": { "@app/common": "/libs/common/src", - "^@app/storage(|/.*)$": "/libs/storage/src/$1", "^@app/infra(|/.*)$": "/libs/infra/src/$1", "^@app/domain(|/.*)$": "/libs/domain/src/$1" }