ソースを参照

refactor(server): asset service - upload asset (#1438)

* refactor: asset upload

* refactor: background service

* chore: tests

* Regenerate api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jason Rasmussen 2 年 前
コミット
9428b2576b
26 ファイル変更460 行追加389 行削除
  1. 1 1
      mobile/openapi/README.md
  2. 1 0
      mobile/openapi/doc/AssetFileUploadResponseDto.md
  3. 11 3
      mobile/openapi/lib/model/asset_file_upload_response_dto.dart
  4. 5 0
      mobile/openapi/test/asset_file_upload_response_dto_test.dart
  5. 15 49
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  6. 20 39
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  7. 52 0
      server/apps/immich/src/api-v1/asset/asset.core.ts
  8. 1 4
      server/apps/immich/src/api-v1/asset/asset.module.ts
  9. 241 124
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  10. 64 130
      server/apps/immich/src/api-v1/asset/asset.service.ts
  11. 22 1
      server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts
  12. 2 5
      server/apps/immich/src/api-v1/asset/response-dto/asset-file-upload-response.dto.ts
  13. 0 3
      server/apps/immich/src/app.module.ts
  14. 0 9
      server/apps/immich/src/modules/background-task/background-task.module.ts
  15. 0 12
      server/apps/immich/src/modules/background-task/background-task.service.ts
  16. 2 0
      server/apps/microservices/src/microservices.module.ts
  17. 1 1
      server/apps/microservices/src/processors/background-task.processor.ts
  18. 4 0
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  19. 5 1
      server/immich-openapi-specs.json
  20. 1 1
      server/libs/infra/src/db/entities/asset.entity.ts
  21. 1 1
      server/libs/storage/src/storage.service.ts
  22. 7 1
      web/src/api/open-api/api.ts
  23. 1 1
      web/src/api/open-api/base.ts
  24. 1 1
      web/src/api/open-api/common.ts
  25. 1 1
      web/src/api/open-api/configuration.ts
  26. 1 1
      web/src/api/open-api/index.ts

+ 1 - 1
mobile/openapi/README.md

@@ -3,7 +3,7 @@ Immich API
 
 This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
-- API version: 1.42.0
+- API version: 1.43.0
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements

+ 1 - 0
mobile/openapi/doc/AssetFileUploadResponseDto.md

@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **id** | **String** |  | 
+**duplicate** | **bool** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 11 - 3
mobile/openapi/lib/model/asset_file_upload_response_dto.dart

@@ -14,25 +14,31 @@ class AssetFileUploadResponseDto {
   /// Returns a new [AssetFileUploadResponseDto] instance.
   AssetFileUploadResponseDto({
     required this.id,
+    required this.duplicate,
   });
 
   String id;
 
+  bool duplicate;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto &&
-     other.id == id;
+     other.id == id &&
+     other.duplicate == duplicate;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
-    (id.hashCode);
+    (id.hashCode) +
+    (duplicate.hashCode);
 
   @override
-  String toString() => 'AssetFileUploadResponseDto[id=$id]';
+  String toString() => 'AssetFileUploadResponseDto[id=$id, duplicate=$duplicate]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
       json[r'id'] = this.id;
+      json[r'duplicate'] = this.duplicate;
     return json;
   }
 
@@ -56,6 +62,7 @@ class AssetFileUploadResponseDto {
 
       return AssetFileUploadResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
+        duplicate: mapValueOfType<bool>(json, r'duplicate')!,
       );
     }
     return null;
@@ -106,6 +113,7 @@ class AssetFileUploadResponseDto {
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
     'id',
+    'duplicate',
   };
 }
 

+ 5 - 0
mobile/openapi/test/asset_file_upload_response_dto_test.dart

@@ -21,6 +21,11 @@ void main() {
       // TODO
     });
 
+    // bool duplicate
+    test('to test the property `duplicate`', () async {
+      // TODO
+    });
+
 
   });
 

+ 15 - 49
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -1,10 +1,9 @@
 import { SearchPropertiesDto } from './dto/search-properties.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { AssetEntity, AssetType } from '@app/infra';
-import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm/repository/Repository';
-import { CreateAssetDto } from './dto/create-asset.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
@@ -19,15 +18,10 @@ import { IsNull, Not } from 'typeorm';
 import { AssetSearchDto } from './dto/asset-search.dto';
 
 export interface IAssetRepository {
-  create(
-    createAssetDto: CreateAssetDto,
-    ownerId: string,
-    originalPath: string,
-    mimeType: string,
-    isVisible: boolean,
-    checksum?: Buffer,
-    livePhotoAssetEntity?: AssetEntity,
-  ): Promise<AssetEntity>;
+  get(id: string): Promise<AssetEntity | null>;
+  create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity>;
+  remove(asset: AssetEntity): Promise<void>;
+
   update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
   getAll(): Promise<AssetEntity[]>;
   getAllVideos(): Promise<AssetEntity[]>;
@@ -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<AssetEntity>
-   */
-  async create(
-    createAssetDto: CreateAssetDto,
-    ownerId: string,
-    originalPath: string,
-    mimeType: string,
-    isVisible: boolean,
-    checksum?: Buffer,
-    livePhotoAssetEntity?: AssetEntity,
-  ): Promise<AssetEntity> {
-    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;
-
-    const createdAsset = await this.assetRepository.save(asset);
-
-    if (!createdAsset) {
-      throw new BadRequestException('Asset not created');
-    }
-    return createdAsset;
+  get(id: string): Promise<AssetEntity | null> {
+    return this.assetRepository.findOne({ where: { id } });
+  }
+
+  async create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity> {
+    return this.assetRepository.save(asset);
+  }
+
+  async remove(asset: AssetEntity): Promise<void> {
+    await this.assetRepository.remove(asset);
   }
 
   /**

+ 20 - 39
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<AssetFileUploadResponseDto> {
-    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<DeleteAssetResponseDto[]> {
-    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);
   }
 
   /**

+ 52 - 0
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<AssetEntity> {
+    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;
+  }
+}

+ 1 - 4
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 {}

+ 241 - 124
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<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
-  let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
-  let storageSeriveMock: jest.Mocked<StorageService>;
+  let storageServiceMock: jest.Mocked<StorageService>;
   let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
-  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<StorageService>;
 
+    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();
-
-    assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
-
-    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,
-    );
+  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));
+      storageServiceMock.moveAsset.mockResolvedValue({ ...assetEntity, originalPath: 'fake_new_path/asset_123.jpeg' });
+
+      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[]>(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<AssetCountByUserIdResponseDto>(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);
     });
   });
 });

+ 64 - 130
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,140 +61,67 @@ 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<AssetEntity>,
-    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;
-
-    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 } });
-      }
+    dto: CreateAssetDto,
+    file: UploadFile,
+    livePhotoFile?: UploadFile,
+  ): Promise<AssetFileUploadResponseDto> {
+    if (livePhotoFile) {
+      livePhotoFile.originalName = file.originalName;
+    }
 
-      const assetEntity = await this.createUserAsset(
-        authUser,
-        createAssetDto,
-        originalAssetData.path,
-        originalAssetData.mimetype,
-        checksum,
-        true,
-        livePhotoAssetEntity,
-      );
+    let livePhotoAsset: AssetEntity | null = null;
 
-      if (!assetEntity) {
-        await this.backgroundTaskService.deleteFileOnDisk([
-          {
-            originalPath: originalAssetData.path,
-          } as any,
-        ]);
-        throw new BadRequestException('Asset not created');
+    try {
+      if (livePhotoFile) {
+        const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
+        livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
       }
 
-      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}`);
-    }
-  }
-
-  public async createUserAsset(
-    authUser: AuthUserDto,
-    createAssetDto: CreateAssetDto,
-    originalPath: string,
-    mimeType: string,
-    checksum: Buffer,
-    isVisible: boolean,
-    livePhotoAssetEntity?: AssetEntity,
-  ): Promise<AssetEntity> {
-    if (!timeUtils.checkValidTimestamp(createAssetDto.createdAt)) {
-      createAssetDto.createdAt = new Date().toISOString();
+      this.logger.error(`Error uploading file ${error}`, error?.stack);
+      throw new BadRequestException(`Error uploading file`, `${error}`);
     }
-
-    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) {
@@ -520,28 +445,37 @@ export class AssetService {
     }
   }
 
-  public async deleteAssetById(assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
+  public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
+    const deleteQueue: AssetEntity[] = [];
     const result: DeleteAssetResponseDto[] = [];
 
-    const target = assetIds.ids;
-    for (const assetId of target) {
-      const res = await this.assetRepository.delete({
-        id: assetId,
-      });
+    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;
+      }
 
-      if (res.affected) {
-        result.push({
-          id: assetId,
-          status: DeleteAssetStatusEnum.SUCCESS,
-        });
-      } else {
-        result.push({
-          id: assetId,
-          status: DeleteAssetStatusEnum.FAILED,
-        });
+      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;
   }
 

+ 22 - 1
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,
+  };
+}

+ 2 - 5
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;
 }

+ 0 - 3
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,

+ 0 - 9
server/apps/immich/src/modules/background-task/background-task.module.ts

@@ -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 {}

+ 0 - 12
server/apps/immich/src/modules/background-task/background-task.service.ts

@@ -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 } });
-  }
-}

+ 2 - 0
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 {}

+ 1 - 1
server/apps/immich/src/modules/background-task/background-task.processor.ts → 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 {

+ 4 - 0
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -235,6 +235,10 @@ export class MetadataExtractionProcessor {
   async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
     const { asset, fileName } = job.data;
 
+    if (!asset.isVisible) {
+      return;
+    }
+
     try {
       const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
         ffmpeg.ffprobe(asset.originalPath, (err, data) => {

+ 5 - 1
server/immich-openapi-specs.json

@@ -3725,10 +3725,14 @@
         "properties": {
           "id": {
             "type": "string"
+          },
+          "duplicate": {
+            "type": "boolean"
           }
         },
         "required": [
-          "id"
+          "id",
+          "duplicate"
         ]
       },
       "DownloadFilesDto": {

+ 1 - 1
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;

+ 1 - 1
server/libs/storage/src/storage.service.ts

@@ -25,7 +25,7 @@ const moveFile = promisify<string, string, mv.Options>(mv);
 
 @Injectable()
 export class StorageService {
-  readonly logger = new Logger(StorageService.name);
+  private readonly logger = new Logger(StorageService.name);
 
   private storageTemplate: HandlebarsTemplateDelegate<any>;
 

+ 7 - 1
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;
 }
 /**
  * 

+ 1 - 1
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).

+ 1 - 1
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).

+ 1 - 1
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).

+ 1 - 1
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).