From 9428b2576b9f8a4d1a60fd11dc82c5ad09cf0a17 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 30 Jan 2023 11:14:13 -0500 Subject: [PATCH] refactor(server): asset service - upload asset (#1438) * refactor: asset upload * refactor: background service * chore: tests * Regenerate api --------- Co-authored-by: Alex Tran --- mobile/openapi/README.md | Bin 13549 -> 13549 bytes .../openapi/doc/AssetFileUploadResponseDto.md | Bin 418 -> 449 bytes .../model/asset_file_upload_response_dto.dart | Bin 3508 -> 3764 bytes .../asset_file_upload_response_dto_test.dart | Bin 590 -> 691 bytes .../src/api-v1/asset/asset-repository.ts | 60 +-- .../src/api-v1/asset/asset.controller.ts | 59 +-- .../immich/src/api-v1/asset/asset.core.ts | 52 +++ .../immich/src/api-v1/asset/asset.module.ts | 5 +- .../src/api-v1/asset/asset.service.spec.ts | 361 ++++++++++++------ .../immich/src/api-v1/asset/asset.service.ts | 196 ++++------ .../src/api-v1/asset/dto/create-asset.dto.ts | 23 +- .../asset-file-upload-response.dto.ts | 7 +- server/apps/immich/src/app.module.ts | 3 - .../background-task/background-task.module.ts | 9 - .../background-task.service.ts | 12 - .../microservices/src/microservices.module.ts | 2 + .../processors}/background-task.processor.ts | 2 +- .../metadata-extraction.processor.ts | 4 + server/immich-openapi-specs.json | 6 +- .../infra/src/db/entities/asset.entity.ts | 2 +- server/libs/storage/src/storage.service.ts | 2 +- web/src/api/open-api/api.ts | 8 +- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- 26 files changed, 439 insertions(+), 382 deletions(-) create mode 100644 server/apps/immich/src/api-v1/asset/asset.core.ts delete mode 100644 server/apps/immich/src/modules/background-task/background-task.module.ts delete mode 100644 server/apps/immich/src/modules/background-task/background-task.service.ts rename server/apps/{immich/src/modules/background-task => microservices/src/processors}/background-task.processor.ts (89%) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 0d94740bd507e94acc1cf042cb4f205bd32930e1..53b96215f5f805b170d2a663979ed2d88d5602d0 100644 GIT binary patch delta 12 TcmaEx`8IRHEJovvvx7_kE5-%g delta 12 TcmaEx`8IRHEJmY^vx7_kE5QZa diff --git a/mobile/openapi/doc/AssetFileUploadResponseDto.md b/mobile/openapi/doc/AssetFileUploadResponseDto.md index 1b4f599e17eebf28f02d6d86b6589db21d9d930d..b945d531c2d5fc711dda7d2b018cb0ba6c7e88df 100644 GIT binary patch delta 32 ocmZ3)e2{sc1WW(` diff --git a/mobile/openapi/lib/model/asset_file_upload_response_dto.dart b/mobile/openapi/lib/model/asset_file_upload_response_dto.dart index d7802e89ea201f856128b1ad0447126207aef41e..33f46cda250344a79352e556d8d93250291a4900 100644 GIT binary patch delta 291 zcmdlYy+wAzHbw^pg`(8L(#)dN6orzE%woNi(t@1KV zkeLGJCFSSmD8Q9XKE~Lts-UI@R0ah3B^jwj2s0FHZIM*^GQ|oiXoD4MAoMR_3KG*n zsIpZ-NPS|;Q&mvND$dV~E>edJtH&zXqPcZ*6!S4=V+DjMRtmX^1!0LfrK$dDA(aKG iHXujaX@HE; + get(id: string): Promise; + create(asset: Omit): Promise; + remove(asset: AssetEntity): Promise; + update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise; getAll(): Promise; getAllVideos(): Promise; @@ -282,44 +276,16 @@ export class AssetRepository implements IAssetRepository { }); } - /** - * Create new asset information in database - * @param createAssetDto - * @param ownerId - * @param originalPath - * @param mimeType - * @returns Promise - */ - async create( - createAssetDto: CreateAssetDto, - ownerId: string, - originalPath: string, - mimeType: string, - isVisible: boolean, - checksum?: Buffer, - livePhotoAssetEntity?: AssetEntity, - ): Promise { - const asset = new AssetEntity(); - asset.deviceAssetId = createAssetDto.deviceAssetId; - asset.userId = ownerId; - asset.deviceId = createAssetDto.deviceId; - asset.type = !isVisible ? AssetType.VIDEO : createAssetDto.assetType || AssetType.OTHER; // If an asset is not visible, it is a LivePhotos video portion, therefore we can confidently assign the type as VIDEO here - asset.originalPath = originalPath; - asset.createdAt = createAssetDto.createdAt; - asset.modifiedAt = createAssetDto.modifiedAt; - asset.isFavorite = createAssetDto.isFavorite; - asset.mimeType = mimeType; - asset.duration = createAssetDto.duration || null; - asset.checksum = checksum || null; - asset.isVisible = isVisible; - asset.livePhotoVideoId = livePhotoAssetEntity ? livePhotoAssetEntity.id : null; + get(id: string): Promise { + return this.assetRepository.findOne({ where: { id } }); + } - const createdAsset = await this.assetRepository.save(asset); + async create(asset: Omit): Promise { + return this.assetRepository.save(asset); + } - if (!createdAsset) { - throw new BadRequestException('Asset not created'); - } - return createdAsset; + async remove(asset: AssetEntity): Promise { + await this.assetRepository.remove(asset); } /** diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 5db2aee25b..ae924dfa3b 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -19,11 +19,9 @@ import { import { Authenticated } from '../../decorators/authenticated.decorator'; import { AssetService } from './asset.service'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; -import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { ServeFileDto } from './dto/serve-file.dto'; import { Response as Res } from 'express'; -import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; @@ -33,9 +31,9 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re import { AssetResponseDto } from '@app/domain'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; import { AssetFileUploadDto } from './dto/asset-file-upload.dto'; -import { CreateAssetDto } from './dto/create-asset.dto'; +import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; -import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; +import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto'; import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto'; import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; @@ -55,12 +53,13 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { SharedLinkResponseDto } from '@app/domain'; import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto'; import { AssetSearchDto } from './dto/asset-search.dto'; +import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; @ApiBearerAuth() @ApiTags('Asset') @Controller('asset') export class AssetController { - constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {} + constructor(private assetService: AssetService) {} @Authenticated({ isShared: true }) @Post('upload') @@ -81,13 +80,22 @@ export class AssetController { async uploadFile( @GetAuthUser() authUser: AuthUserDto, @UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] }, - @Body(ValidationPipe) createAssetDto: CreateAssetDto, + @Body(ValidationPipe) dto: CreateAssetDto, @Response({ passthrough: true }) res: Res, ): Promise { - const originalAssetData = files.assetData[0]; - const livePhotoAssetData = files.livePhotoData?.[0]; + const file = mapToUploadFile(files.assetData[0]); + const _livePhotoFile = files.livePhotoData?.[0]; + let livePhotoFile; + if (_livePhotoFile) { + livePhotoFile = mapToUploadFile(_livePhotoFile); + } - return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData); + const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile); + if (responseDto.duplicate) { + res.send(200); + } + + return responseDto; } @Authenticated({ isShared: true }) @@ -276,37 +284,10 @@ export class AssetController { @Delete('/') async deleteAsset( @GetAuthUser() authUser: AuthUserDto, - @Body(ValidationPipe) assetIds: DeleteAssetDto, + @Body(ValidationPipe) dto: DeleteAssetDto, ): Promise { - await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true); - - const deleteAssetList: AssetResponseDto[] = []; - - for (const id of assetIds.ids) { - const assets = await this.assetService.getAssetById(authUser, id); - if (!assets) { - continue; - } - deleteAssetList.push(assets); - - if (assets.livePhotoVideoId) { - const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId); - if (livePhotoVideo) { - deleteAssetList.push(livePhotoVideo); - assetIds.ids = [...assetIds.ids, livePhotoVideo.id]; - } - } - } - - const result = await this.assetService.deleteAssetById(assetIds); - - result.forEach((res) => { - deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS); - }); - - await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList as any[]); - - return result; + await this.assetService.checkAssetsAccess(authUser, dto.ids, true); + return this.assetService.deleteAll(authUser, dto); } /** diff --git a/server/apps/immich/src/api-v1/asset/asset.core.ts b/server/apps/immich/src/api-v1/asset/asset.core.ts new file mode 100644 index 0000000000..5ad47c70ab --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/asset.core.ts @@ -0,0 +1,52 @@ +import { timeUtils } from '@app/common'; +import { AuthUserDto, IJobRepository, JobName } from '@app/domain'; +import { AssetEntity } from '@app/infra/db/entities'; +import { StorageService } from '@app/storage'; +import { IAssetRepository } from './asset-repository'; +import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; + +export class AssetCore { + constructor( + private repository: IAssetRepository, + private jobRepository: IJobRepository, + private storageService: StorageService, + ) {} + + async create( + authUser: AuthUserDto, + dto: CreateAssetDto, + file: UploadFile, + livePhotoAssetId?: string, + ): Promise { + let asset = await this.repository.create({ + userId: authUser.id, + + mimeType: file.mimeType, + checksum: file.checksum || null, + originalPath: file.originalPath, + + createdAt: timeUtils.checkValidTimestamp(dto.createdAt) ? dto.createdAt : new Date().toISOString(), + modifiedAt: timeUtils.checkValidTimestamp(dto.modifiedAt) ? dto.modifiedAt : new Date().toISOString(), + + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + + type: dto.assetType, + isFavorite: dto.isFavorite, + duration: dto.duration || null, + isVisible: dto.isVisible ?? true, + livePhotoVideoId: livePhotoAssetId || null, + resizePath: null, + webpPath: null, + encodedVideoPath: null, + tags: [], + sharedLinks: [], + }); + + asset = await this.storageService.moveAsset(asset, file.originalName); + + await this.jobRepository.add({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }); + + 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 8bc401e761..037a12b2b6 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -3,8 +3,6 @@ import { AssetService } from './asset.service'; import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AssetEntity } from '@app/infra'; -import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; -import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { CommunicationModule } from '../communication/communication.module'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { DownloadModule } from '../../modules/download/download.module'; @@ -21,14 +19,13 @@ const ASSET_REPOSITORY_PROVIDER = { imports: [ TypeOrmModule.forFeature([AssetEntity]), CommunicationModule, - BackgroundTaskModule, DownloadModule, TagModule, StorageModule, forwardRef(() => AlbumModule), ], controllers: [AssetController], - providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER], + providers: [AssetService, ASSET_REPOSITORY_PROVIDER], exports: [ASSET_REPOSITORY_PROVIDER], }) export class AssetModule {} 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 7dfb31cbec..c215e22fe5 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 @@ -1,17 +1,15 @@ import { IAssetRepository } from './asset-repository'; -import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AssetService } from './asset.service'; -import { Repository } from 'typeorm'; +import { QueryFailedError, Repository } from 'typeorm'; import { AssetEntity, AssetType } from '@app/infra'; import { CreateAssetDto } from './dto/create-asset.dto'; import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; 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 { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { AlbumRepository, IAlbumRepository } from '../album/album-repository'; import { StorageService } from '@app/storage'; -import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain'; +import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain'; import { authStub, newCryptoRepositoryMock, @@ -23,105 +21,102 @@ import { import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; +const _getCreateAssetDto = (): CreateAssetDto => { + const createAssetDto = new CreateAssetDto(); + createAssetDto.deviceAssetId = 'deviceAssetId'; + createAssetDto.deviceId = 'deviceId'; + createAssetDto.assetType = AssetType.OTHER; + createAssetDto.createdAt = '2022-06-19T23:41:36.910Z'; + createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z'; + createAssetDto.isFavorite = false; + createAssetDto.duration = '0:00:00.000000'; + + return createAssetDto; +}; + +const _getAsset_1 = () => { + const asset_1 = new AssetEntity(); + + asset_1.id = 'id_1'; + asset_1.userId = 'user_id_1'; + asset_1.deviceAssetId = 'device_asset_id_1'; + asset_1.deviceId = 'device_id_1'; + asset_1.type = AssetType.VIDEO; + asset_1.originalPath = 'fake_path/asset_1.jpeg'; + asset_1.resizePath = ''; + asset_1.createdAt = '2022-06-19T23:41:36.910Z'; + asset_1.modifiedAt = '2022-06-19T23:41:36.910Z'; + asset_1.isFavorite = false; + asset_1.mimeType = 'image/jpeg'; + asset_1.webpPath = ''; + asset_1.encodedVideoPath = ''; + asset_1.duration = '0:00:00.000000'; + return asset_1; +}; + +const _getAsset_2 = () => { + const asset_2 = new AssetEntity(); + + asset_2.id = 'id_2'; + asset_2.userId = 'user_id_1'; + asset_2.deviceAssetId = 'device_asset_id_2'; + asset_2.deviceId = 'device_id_1'; + asset_2.type = AssetType.VIDEO; + asset_2.originalPath = 'fake_path/asset_2.jpeg'; + asset_2.resizePath = ''; + asset_2.createdAt = '2022-06-19T23:41:36.910Z'; + asset_2.modifiedAt = '2022-06-19T23:41:36.910Z'; + asset_2.isFavorite = false; + asset_2.mimeType = 'image/jpeg'; + asset_2.webpPath = ''; + asset_2.encodedVideoPath = ''; + asset_2.duration = '0:00:00.000000'; + + return asset_2; +}; + +const _getAssets = () => { + return [_getAsset_1(), _getAsset_2()]; +}; + +const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => { + const result1 = new AssetCountByTimeBucket(); + result1.count = 2; + result1.timeBucket = '2022-06-01T00:00:00.000Z'; + + const result2 = new AssetCountByTimeBucket(); + result1.count = 5; + result1.timeBucket = '2022-07-01T00:00:00.000Z'; + + return [result1, result2]; +}; + +const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => { + const result = new AssetCountByUserIdResponseDto(); + + result.videos = 2; + result.photos = 2; + + return result; +}; + describe('AssetService', () => { - let sui: AssetService; + let sut: AssetService; let a: Repository; // TO BE DELETED AFTER FINISHED REFACTORING let assetRepositoryMock: jest.Mocked; let albumRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; - let backgroundTaskServiceMock: jest.Mocked; - let storageSeriveMock: jest.Mocked; + let storageServiceMock: jest.Mocked; let sharedLinkRepositoryMock: jest.Mocked; let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; - const authUser: AuthUserDto = Object.freeze({ - id: 'user_id_1', - email: 'auth@test.com', - isAdmin: false, - }); - const _getCreateAssetDto = (): CreateAssetDto => { - const createAssetDto = new CreateAssetDto(); - createAssetDto.deviceAssetId = 'deviceAssetId'; - createAssetDto.deviceId = 'deviceId'; - createAssetDto.assetType = AssetType.OTHER; - createAssetDto.createdAt = '2022-06-19T23:41:36.910Z'; - createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z'; - createAssetDto.isFavorite = false; - createAssetDto.duration = '0:00:00.000000'; - - return createAssetDto; - }; - - const _getAsset_1 = () => { - const asset_1 = new AssetEntity(); - - asset_1.id = 'id_1'; - asset_1.userId = 'user_id_1'; - asset_1.deviceAssetId = 'device_asset_id_1'; - asset_1.deviceId = 'device_id_1'; - asset_1.type = AssetType.VIDEO; - asset_1.originalPath = 'fake_path/asset_1.jpeg'; - asset_1.resizePath = ''; - asset_1.createdAt = '2022-06-19T23:41:36.910Z'; - asset_1.modifiedAt = '2022-06-19T23:41:36.910Z'; - asset_1.isFavorite = false; - asset_1.mimeType = 'image/jpeg'; - asset_1.webpPath = ''; - asset_1.encodedVideoPath = ''; - asset_1.duration = '0:00:00.000000'; - return asset_1; - }; - - const _getAsset_2 = () => { - const asset_2 = new AssetEntity(); - - asset_2.id = 'id_2'; - asset_2.userId = 'user_id_1'; - asset_2.deviceAssetId = 'device_asset_id_2'; - asset_2.deviceId = 'device_id_1'; - asset_2.type = AssetType.VIDEO; - asset_2.originalPath = 'fake_path/asset_2.jpeg'; - asset_2.resizePath = ''; - asset_2.createdAt = '2022-06-19T23:41:36.910Z'; - asset_2.modifiedAt = '2022-06-19T23:41:36.910Z'; - asset_2.isFavorite = false; - asset_2.mimeType = 'image/jpeg'; - asset_2.webpPath = ''; - asset_2.encodedVideoPath = ''; - asset_2.duration = '0:00:00.000000'; - - return asset_2; - }; - - const _getAssets = () => { - return [_getAsset_1(), _getAsset_2()]; - }; - - const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => { - const result1 = new AssetCountByTimeBucket(); - result1.count = 2; - result1.timeBucket = '2022-06-01T00:00:00.000Z'; - - const result2 = new AssetCountByTimeBucket(); - result1.count = 5; - result1.timeBucket = '2022-07-01T00:00:00.000Z'; - - return [result1, result2]; - }; - - const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => { - const result = new AssetCountByUserIdResponseDto(); - - result.videos = 2; - result.photos = 2; - - return result; - }; - - beforeAll(() => { + beforeEach(() => { assetRepositoryMock = { + get: jest.fn(), create: jest.fn(), + remove: jest.fn(), + update: jest.fn(), getAll: jest.fn(), getAllVideos: jest.fn(), @@ -151,18 +146,21 @@ describe('AssetService', () => { downloadArchive: jest.fn(), }; - sharedLinkRepositoryMock = newSharedLinkRepositoryMock(); + storageServiceMock = { + moveAsset: jest.fn(), + removeEmptyDirectories: jest.fn(), + } as unknown as jest.Mocked; + sharedLinkRepositoryMock = newSharedLinkRepositoryMock(); jobMock = newJobRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); - sui = new AssetService( + sut = new AssetService( assetRepositoryMock, albumRepositoryMock, a, - backgroundTaskServiceMock, downloadServiceMock as DownloadService, - storageSeriveMock, + storageServiceMock, sharedLinkRepositoryMock, jobMock, cryptoMock, @@ -178,7 +176,7 @@ describe('AssetService', () => { assetRepositoryMock.countByIdAndUser.mockResolvedValue(1); sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid); - await expect(sui.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid); + await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid); expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id); expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id); @@ -196,7 +194,7 @@ describe('AssetService', () => { sharedLinkRepositoryMock.get.mockResolvedValue(null); sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true); - await expect(sui.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException); expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id); expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId); @@ -215,7 +213,7 @@ describe('AssetService', () => { sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true); sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid); - await expect(sui.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid); + await expect(sut.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid); expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id); expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId); @@ -223,27 +221,94 @@ describe('AssetService', () => { }); }); - // Currently failing due to calculate checksum from a file - it('create an asset', async () => { - const assetEntity = _getAsset_1(); + describe('uploadFile', () => { + it('should handle a file upload', async () => { + const assetEntity = _getAsset_1(); + const file = { + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + }; + const dto = _getCreateAssetDto(); - assetRepositoryMock.create.mockImplementation(() => Promise.resolve(assetEntity)); + assetRepositoryMock.create.mockImplementation(() => Promise.resolve(assetEntity)); + storageServiceMock.moveAsset.mockResolvedValue({ ...assetEntity, originalPath: 'fake_new_path/asset_123.jpeg' }); - const originalPath = 'fake_path/asset_1.jpeg'; - const mimeType = 'image/jpeg'; - const createAssetDto = _getCreateAssetDto(); - const result = await sui.createUserAsset( - authUser, - createAssetDto, - originalPath, - mimeType, - Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'), - true, - ); + await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); + }); - expect(result.userId).toEqual(authUser.id); - expect(result.resizePath).toEqual(''); - expect(result.webpPath).toEqual(''); + it('should handle a duplicate', async () => { + const file = { + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + }; + const dto = _getCreateAssetDto(); + const error = new QueryFailedError('', [], ''); + (error as any).constraint = 'UQ_userid_checksum'; + + assetRepositoryMock.create.mockRejectedValue(error); + assetRepositoryMock.getAssetByChecksum.mockResolvedValue(_getAsset_1()); + + 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(storageServiceMock.moveAsset).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, + userId: 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, + userId: 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)); + + await expect(sut.uploadFile(authStub.user1, dto, file, livePhotoFile)).resolves.toEqual({ + duplicate: false, + id: 'live-photo-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 } }], + ]); + }); }); it('get assets by device id', async () => { @@ -254,7 +319,7 @@ describe('AssetService', () => { ); const deviceId = 'device_id_1'; - const result = await sui.getUserAssetsByDeviceId(authUser, deviceId); + const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); expect(result.length).toEqual(2); expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); @@ -267,7 +332,7 @@ describe('AssetService', () => { Promise.resolve(assetCountByTimeBucket), ); - const result = await sui.getAssetCountByTimeBucket(authUser, { + const result = await sut.getAssetCountByTimeBucket(authStub.user1, { timeGroup: TimeGroupEnum.Month, }); @@ -282,18 +347,70 @@ describe('AssetService', () => { Promise.resolve(assetCount), ); - const result = await sui.getAssetCountByUserId(authUser); + const result = await sut.getAssetCountByUserId(authStub.user1); expect(result).toEqual(assetCount); }); + describe('deleteAll', () => { + it('should return failed status when an asset is missing', async () => { + assetRepositoryMock.get.mockResolvedValue(null); + + await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([ + { id: 'asset1', status: 'FAILED' }, + ]); + + expect(jobMock.add).not.toHaveBeenCalled(); + }); + + it('should return failed status a delete fails', async () => { + assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity); + assetRepositoryMock.remove.mockRejectedValue('delete failed'); + + await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([ + { id: 'asset1', status: 'FAILED' }, + ]); + + expect(jobMock.add).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' }, + ]); + + expect(jobMock.add).toHaveBeenCalledWith({ + name: JobName.DELETE_FILE_ON_DISK, + data: { assets: [{ id: 'asset1', livePhotoVideoId: 'live-photo' }, { id: 'live-photo' }] }, + }); + }); + + it('should delete a batch of assets', async () => { + assetRepositoryMock.get.mockImplementation((id) => Promise.resolve({ id } as AssetEntity)); + assetRepositoryMock.remove.mockImplementation(() => Promise.resolve()); + + 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' }] } }], + ]); + }); + }); + describe('checkDownloadAccess', () => { it('should validate download access', async () => { - await sui.checkDownloadAccess(authStub.adminSharedLink); + await sut.checkDownloadAccess(authStub.adminSharedLink); }); it('should not allow when user is not allowed to download', async () => { - expect(() => sui.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); + expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); }); }); }); 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 b7765f49fd..1e2c8bba83 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -23,8 +23,8 @@ import { SearchAssetDto } from './dto/search-asset.dto'; import fs from 'fs/promises'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; -import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain'; -import { CreateAssetDto } from './dto/create-asset.dto'; +import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain'; +import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; @@ -37,13 +37,12 @@ import { import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; -import { timeUtils } from '@app/common/utils'; +import { AssetCore } from './asset.core'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; -import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; -import { ICryptoRepository, IJobRepository, JobName } from '@app/domain'; +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'; @@ -55,7 +54,6 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { mapSharedLink, SharedLinkResponseDto } from '@app/domain'; import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto'; import { AssetSearchDto } from './dto/asset-search.dto'; -import { ImmichFile } from '../../config/asset-upload.config'; const fileInfo = promisify(stat); @@ -63,142 +61,69 @@ const fileInfo = promisify(stat); export class AssetService { readonly logger = new Logger(AssetService.name); private shareCore: ShareCore; + private assetCore: AssetCore; constructor( @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository, @InjectRepository(AssetEntity) private assetRepository: Repository, - private backgroundTaskService: BackgroundTaskService, private downloadService: DownloadService, - private storageService: StorageService, + storageService: StorageService, @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, ) { + this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService); this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); } - public async handleUploadedAsset( + public async uploadFile( authUser: AuthUserDto, - createAssetDto: CreateAssetDto, - res: Res, - originalAssetData: ImmichFile, - livePhotoAssetData?: ImmichFile, - ) { - const checksum = originalAssetData.checksum; - const isLivePhoto = livePhotoAssetData !== undefined; - let livePhotoAssetEntity: AssetEntity | undefined; + dto: CreateAssetDto, + file: UploadFile, + livePhotoFile?: UploadFile, + ): Promise { + if (livePhotoFile) { + livePhotoFile.originalName = file.originalName; + } + + let livePhotoAsset: AssetEntity | null = null; try { - if (isLivePhoto) { - const livePhotoChecksum = livePhotoAssetData.checksum; - livePhotoAssetEntity = await this.createUserAsset( - authUser, - createAssetDto, - livePhotoAssetData.path, - livePhotoAssetData.mimetype, - livePhotoChecksum, - false, - ); - - if (!livePhotoAssetEntity) { - await this.backgroundTaskService.deleteFileOnDisk([ - { - originalPath: livePhotoAssetData.path, - } as any, - ]); - throw new BadRequestException('Asset not created'); - } - - await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname); - - await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset: livePhotoAssetEntity } }); + if (livePhotoFile) { + const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false }; + livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile); } - const assetEntity = await this.createUserAsset( - authUser, - createAssetDto, - originalAssetData.path, - originalAssetData.mimetype, - checksum, - true, - livePhotoAssetEntity, - ); - - if (!assetEntity) { - await this.backgroundTaskService.deleteFileOnDisk([ - { - originalPath: originalAssetData.path, - } as any, - ]); - throw new BadRequestException('Asset not created'); - } - - const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname); + const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id); + return { id: asset.id, duplicate: false }; + } catch (error: any) { + // clean up files await this.jobRepository.add({ - name: JobName.ASSET_UPLOADED, - data: { asset: movedAsset, fileName: originalAssetData.originalname }, + name: JobName.DELETE_FILE_ON_DISK, + data: { + assets: [ + { + originalPath: file.originalPath, + resizePath: livePhotoFile?.originalPath || null, + } as AssetEntity, + ], + }, }); - return new AssetFileUploadResponseDto(movedAsset.id); - } catch (err) { - await this.backgroundTaskService.deleteFileOnDisk([ - { - originalPath: originalAssetData.path, - } as any, - ]); // simulate asset to make use of delete queue (or use fs.unlink instead) - - if (isLivePhoto) { - await this.backgroundTaskService.deleteFileOnDisk([ - { - originalPath: livePhotoAssetData.path, - } as any, - ]); + // handle duplicates with a success response + if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') { + const duplicate = await this.getAssetByChecksum(authUser.id, file.checksum); + return { id: duplicate.id, duplicate: true }; } - if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') { - const existedAsset = await this.getAssetByChecksum(authUser.id, checksum); - res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists - return new AssetFileUploadResponseDto(existedAsset.id); - } - - Logger.error(`Error uploading file ${err}`); - throw new BadRequestException(`Error uploading file`, `${err}`); + this.logger.error(`Error uploading file ${error}`, error?.stack); + throw new BadRequestException(`Error uploading file`, `${error}`); } } - public async createUserAsset( - authUser: AuthUserDto, - createAssetDto: CreateAssetDto, - originalPath: string, - mimeType: string, - checksum: Buffer, - isVisible: boolean, - livePhotoAssetEntity?: AssetEntity, - ): Promise { - if (!timeUtils.checkValidTimestamp(createAssetDto.createdAt)) { - createAssetDto.createdAt = new Date().toISOString(); - } - - if (!timeUtils.checkValidTimestamp(createAssetDto.modifiedAt)) { - createAssetDto.modifiedAt = new Date().toISOString(); - } - - const assetEntity = await this._assetRepository.create( - createAssetDto, - authUser.id, - originalPath, - mimeType, - isVisible, - checksum, - livePhotoAssetEntity, - ); - - return assetEntity; - } - public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { return this._assetRepository.getAllByDeviceId(authUser.id, deviceId); } @@ -520,26 +445,35 @@ export class AssetService { } } - public async deleteAssetById(assetIds: DeleteAssetDto): Promise { + public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise { + const deleteQueue: AssetEntity[] = []; const result: DeleteAssetResponseDto[] = []; - const target = assetIds.ids; - for (const assetId of target) { - const res = await this.assetRepository.delete({ - id: assetId, - }); - - if (res.affected) { - result.push({ - id: assetId, - status: DeleteAssetStatusEnum.SUCCESS, - }); - } else { - result.push({ - id: assetId, - status: DeleteAssetStatusEnum.FAILED, - }); + const ids = dto.ids.slice(); + for (const id of ids) { + const asset = await this._assetRepository.get(id); + if (!asset) { + result.push({ id, status: DeleteAssetStatusEnum.FAILED }); + continue; } + + try { + await this._assetRepository.remove(asset); + + result.push({ id: asset.id, status: DeleteAssetStatusEnum.SUCCESS }); + deleteQueue.push(asset as any); + + // TODO refactor this to use cascades + if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { + ids.push(asset.livePhotoVideoId); + } + } catch { + result.push({ id, status: DeleteAssetStatusEnum.FAILED }); + } + } + + if (deleteQueue.length > 0) { + await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets: deleteQueue } }); } return result; diff --git a/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts b/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts index de5b42b234..0d6af77bca 100644 --- a/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts @@ -1,6 +1,7 @@ -import { IsNotEmpty, IsOptional } from 'class-validator'; import { AssetType } from '@app/infra'; import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator'; +import { ImmichFile } from '../../../config/asset-upload.config'; export class CreateAssetDto { @IsNotEmpty() @@ -22,9 +23,29 @@ export class CreateAssetDto { @IsNotEmpty() isFavorite!: boolean; + @IsOptional() + @IsBoolean() + isVisible?: boolean; + @IsNotEmpty() fileExtension!: string; @IsOptional() duration?: string; } + +export interface UploadFile { + mimeType: string; + checksum: Buffer; + originalPath: string; + originalName: string; +} + +export function mapToUploadFile(file: ImmichFile): UploadFile { + return { + checksum: file.checksum, + mimeType: file.mimetype, + originalPath: file.path, + originalName: file.originalname, + }; +} diff --git a/server/apps/immich/src/api-v1/asset/response-dto/asset-file-upload-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/asset-file-upload-response.dto.ts index 7d3b6beb3c..f628b708dc 100644 --- a/server/apps/immich/src/api-v1/asset/response-dto/asset-file-upload-response.dto.ts +++ b/server/apps/immich/src/api-v1/asset/response-dto/asset-file-upload-response.dto.ts @@ -1,7 +1,4 @@ export class AssetFileUploadResponseDto { - constructor(id: string) { - this.id = id; - } - - id: string; + id!: string; + duplicate!: boolean; } diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 1001f80d82..586b29f100 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -4,7 +4,6 @@ import { AssetModule } from './api-v1/asset/asset.module'; import { DeviceInfoModule } from './api-v1/device-info/device-info.module'; import { ConfigModule } from '@nestjs/config'; import { ServerInfoModule } from './api-v1/server-info/server-info.module'; -import { BackgroundTaskModule } from './modules/background-task/background-task.module'; import { CommunicationModule } from './api-v1/communication/communication.module'; import { AlbumModule } from './api-v1/album/album.module'; import { AppController } from './app.controller'; @@ -40,8 +39,6 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str ServerInfoModule, - BackgroundTaskModule, - CommunicationModule, AlbumModule, diff --git a/server/apps/immich/src/modules/background-task/background-task.module.ts b/server/apps/immich/src/modules/background-task/background-task.module.ts deleted file mode 100644 index fa52b6b44d..0000000000 --- a/server/apps/immich/src/modules/background-task/background-task.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { BackgroundTaskProcessor } from './background-task.processor'; -import { BackgroundTaskService } from './background-task.service'; - -@Module({ - providers: [BackgroundTaskService, BackgroundTaskProcessor], - exports: [BackgroundTaskService], -}) -export class BackgroundTaskModule {} diff --git a/server/apps/immich/src/modules/background-task/background-task.service.ts b/server/apps/immich/src/modules/background-task/background-task.service.ts deleted file mode 100644 index b32a89b266..0000000000 --- a/server/apps/immich/src/modules/background-task/background-task.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IJobRepository, JobName } from '@app/domain'; -import { AssetEntity } from '@app/infra'; -import { Inject, Injectable } from '@nestjs/common'; - -@Injectable() -export class BackgroundTaskService { - constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {} - - async deleteFileOnDisk(assets: AssetEntity[]) { - await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets } }); - } -} diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 0e2f2064ea..146f48d153 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -14,6 +14,7 @@ import { StorageMigrationProcessor } from './processors/storage-migration.proces 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({ @@ -37,6 +38,7 @@ import { DomainModule } from '@app/domain'; MachineLearningProcessor, UserDeletionProcessor, StorageMigrationProcessor, + BackgroundTaskProcessor, ], }) export class MicroservicesModule {} diff --git a/server/apps/immich/src/modules/background-task/background-task.processor.ts b/server/apps/microservices/src/processors/background-task.processor.ts similarity index 89% rename from server/apps/immich/src/modules/background-task/background-task.processor.ts rename to server/apps/microservices/src/processors/background-task.processor.ts index 0df0d0ada6..34799044f3 100644 --- a/server/apps/immich/src/modules/background-task/background-task.processor.ts +++ b/server/apps/microservices/src/processors/background-task.processor.ts @@ -2,7 +2,7 @@ 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'; +import { AssetEntity } from '@app/infra/db/entities'; @Processor(QueueName.BACKGROUND_TASK) export class BackgroundTaskProcessor { diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 36f9ca3cb6..1f60846ca0 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -235,6 +235,10 @@ export class MetadataExtractionProcessor { async extractVideoMetadata(job: Job) { const { asset, fileName } = job.data; + if (!asset.isVisible) { + return; + } + try { const data = await new Promise((resolve, reject) => ffmpeg.ffprobe(asset.originalPath, (err, data) => { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 5f03687126..d3ed92ad09 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3725,10 +3725,14 @@ "properties": { "id": { "type": "string" + }, + "duplicate": { + "type": "boolean" } }, "required": [ - "id" + "id", + "duplicate" ] }, "DownloadFilesDto": { diff --git a/server/libs/infra/src/db/entities/asset.entity.ts b/server/libs/infra/src/db/entities/asset.entity.ts index 9fcb8be5ed..532793e1c3 100644 --- a/server/libs/infra/src/db/entities/asset.entity.ts +++ b/server/libs/infra/src/db/entities/asset.entity.ts @@ -32,7 +32,7 @@ export class AssetEntity { webpPath!: string | null; @Column({ type: 'varchar', nullable: true, default: '' }) - encodedVideoPath!: string; + encodedVideoPath!: string | null; @Column({ type: 'timestamptz' }) createdAt!: string; diff --git a/server/libs/storage/src/storage.service.ts b/server/libs/storage/src/storage.service.ts index 3b601e0c41..08b7bc185b 100644 --- a/server/libs/storage/src/storage.service.ts +++ b/server/libs/storage/src/storage.service.ts @@ -25,7 +25,7 @@ const moveFile = promisify(mv); @Injectable() export class StorageService { - readonly logger = new Logger(StorageService.name); + private readonly logger = new Logger(StorageService.name); private storageTemplate: HandlebarsTemplateDelegate; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index a128f4f51e..8eb39ea7c9 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.42.0 + * The version of the OpenAPI document: 1.43.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -395,6 +395,12 @@ export interface AssetFileUploadResponseDto { * @memberof AssetFileUploadResponseDto */ 'id': string; + /** + * + * @type {boolean} + * @memberof AssetFileUploadResponseDto + */ + 'duplicate': boolean; } /** * diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 3902b9e4f8..58f76152d5 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.42.0 + * The version of the OpenAPI document: 1.43.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index a2e7728e2e..2df54adf62 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.42.0 + * The version of the OpenAPI document: 1.43.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index ccb51fb3f9..bb49e3c84c 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.42.0 + * The version of the OpenAPI document: 1.43.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index bcbb0bd7a0..9b6a043311 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.42.0 + * The version of the OpenAPI document: 1.43.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).