mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	feat(server): harden move file (#4361)
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		
							parent
							
								
									332a8d80f2
								
							
						
					
					
						commit
						09bf1c9175
					
				@ -66,6 +66,10 @@ ORDER BY
 | 
				
			|||||||
  "users"."email";
 | 
					  "users"."email";
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```sql title="Failed file movements"
 | 
				
			||||||
 | 
					SELECT * FROM "move_history";
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Users
 | 
					## Users
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```sql title="List"
 | 
					```sql title="List"
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,8 @@ import {
 | 
				
			|||||||
  newCommunicationRepositoryMock,
 | 
					  newCommunicationRepositoryMock,
 | 
				
			||||||
  newCryptoRepositoryMock,
 | 
					  newCryptoRepositoryMock,
 | 
				
			||||||
  newJobRepositoryMock,
 | 
					  newJobRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
  newSystemConfigRepositoryMock,
 | 
					  newSystemConfigRepositoryMock,
 | 
				
			||||||
} from '@test';
 | 
					} from '@test';
 | 
				
			||||||
@ -22,6 +24,8 @@ import {
 | 
				
			|||||||
  ICommunicationRepository,
 | 
					  ICommunicationRepository,
 | 
				
			||||||
  ICryptoRepository,
 | 
					  ICryptoRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  ISystemConfigRepository,
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
  JobItem,
 | 
					  JobItem,
 | 
				
			||||||
@ -160,6 +164,8 @@ describe(AssetService.name, () => {
 | 
				
			|||||||
  let assetMock: jest.Mocked<IAssetRepository>;
 | 
					  let assetMock: jest.Mocked<IAssetRepository>;
 | 
				
			||||||
  let cryptoMock: jest.Mocked<ICryptoRepository>;
 | 
					  let cryptoMock: jest.Mocked<ICryptoRepository>;
 | 
				
			||||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
					  let jobMock: jest.Mocked<IJobRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
  let communicationMock: jest.Mocked<ICommunicationRepository>;
 | 
					  let communicationMock: jest.Mocked<ICommunicationRepository>;
 | 
				
			||||||
  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
					  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
				
			||||||
@ -174,9 +180,21 @@ describe(AssetService.name, () => {
 | 
				
			|||||||
    communicationMock = newCommunicationRepositoryMock();
 | 
					    communicationMock = newCommunicationRepositoryMock();
 | 
				
			||||||
    cryptoMock = newCryptoRepositoryMock();
 | 
					    cryptoMock = newCryptoRepositoryMock();
 | 
				
			||||||
    jobMock = newJobRepositoryMock();
 | 
					    jobMock = newJobRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
    configMock = newSystemConfigRepositoryMock();
 | 
					    configMock = newSystemConfigRepositoryMock();
 | 
				
			||||||
    sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock);
 | 
					    sut = new AssetService(
 | 
				
			||||||
 | 
					      accessMock,
 | 
				
			||||||
 | 
					      assetMock,
 | 
				
			||||||
 | 
					      cryptoMock,
 | 
				
			||||||
 | 
					      jobMock,
 | 
				
			||||||
 | 
					      configMock,
 | 
				
			||||||
 | 
					      moveMock,
 | 
				
			||||||
 | 
					      personMock,
 | 
				
			||||||
 | 
					      storageMock,
 | 
				
			||||||
 | 
					      communicationMock,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    when(assetMock.getById)
 | 
					    when(assetMock.getById)
 | 
				
			||||||
      .calledWith(assetStub.livePhotoStillAsset.id)
 | 
					      .calledWith(assetStub.livePhotoStillAsset.id)
 | 
				
			||||||
 | 
				
			|||||||
@ -16,6 +16,8 @@ import {
 | 
				
			|||||||
  ICommunicationRepository,
 | 
					  ICommunicationRepository,
 | 
				
			||||||
  ICryptoRepository,
 | 
					  ICryptoRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  ISystemConfigRepository,
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
  ImmichReadStream,
 | 
					  ImmichReadStream,
 | 
				
			||||||
@ -80,12 +82,14 @@ export class AssetService {
 | 
				
			|||||||
    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
 | 
					    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
 | 
				
			||||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
					    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
    @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
 | 
					    @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.access = new AccessCore(accessRepository);
 | 
					    this.access = new AccessCore(accessRepository);
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					 | 
				
			||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
 | 
					  canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
 | 
				
			||||||
 | 
				
			|||||||
@ -14,6 +14,7 @@ import {
 | 
				
			|||||||
  newAssetRepositoryMock,
 | 
					  newAssetRepositoryMock,
 | 
				
			||||||
  newJobRepositoryMock,
 | 
					  newJobRepositoryMock,
 | 
				
			||||||
  newMediaRepositoryMock,
 | 
					  newMediaRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
  newPersonRepositoryMock,
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
  newSystemConfigRepositoryMock,
 | 
					  newSystemConfigRepositoryMock,
 | 
				
			||||||
@ -25,6 +26,7 @@ import {
 | 
				
			|||||||
  IAssetRepository,
 | 
					  IAssetRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  IMediaRepository,
 | 
					  IMediaRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
  IPersonRepository,
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  ISystemConfigRepository,
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
@ -38,6 +40,7 @@ describe(MediaService.name, () => {
 | 
				
			|||||||
  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
					  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
				
			||||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
					  let jobMock: jest.Mocked<IJobRepository>;
 | 
				
			||||||
  let mediaMock: jest.Mocked<IMediaRepository>;
 | 
					  let mediaMock: jest.Mocked<IMediaRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
  let personMock: jest.Mocked<IPersonRepository>;
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -46,10 +49,11 @@ describe(MediaService.name, () => {
 | 
				
			|||||||
    configMock = newSystemConfigRepositoryMock();
 | 
					    configMock = newSystemConfigRepositoryMock();
 | 
				
			||||||
    jobMock = newJobRepositoryMock();
 | 
					    jobMock = newJobRepositoryMock();
 | 
				
			||||||
    mediaMock = newMediaRepositoryMock();
 | 
					    mediaMock = newMediaRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
    personMock = newPersonRepositoryMock();
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock);
 | 
					    sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock, moveMock);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should be defined', () => {
 | 
					  it('should be defined', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,12 @@
 | 
				
			|||||||
import { AssetEntity, AssetType, Colorspace, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities';
 | 
					import {
 | 
				
			||||||
 | 
					  AssetEntity,
 | 
				
			||||||
 | 
					  AssetPathType,
 | 
				
			||||||
 | 
					  AssetType,
 | 
				
			||||||
 | 
					  Colorspace,
 | 
				
			||||||
 | 
					  TranscodeHWAccel,
 | 
				
			||||||
 | 
					  TranscodePolicy,
 | 
				
			||||||
 | 
					  VideoCodec,
 | 
				
			||||||
 | 
					} from '@app/infra/entities';
 | 
				
			||||||
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
 | 
					import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
 | 
				
			||||||
import { usePagination } from '../domain.util';
 | 
					import { usePagination } from '../domain.util';
 | 
				
			||||||
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
 | 
					import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
 | 
				
			||||||
@ -7,6 +15,7 @@ import {
 | 
				
			|||||||
  IAssetRepository,
 | 
					  IAssetRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  IMediaRepository,
 | 
					  IMediaRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
  IPersonRepository,
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  ISystemConfigRepository,
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
@ -32,9 +41,10 @@ export class MediaService {
 | 
				
			|||||||
    @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
 | 
					    @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
    this.storageCore = new StorageCore(this.storageRepository);
 | 
					    this.storageCore = new StorageCore(this.storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async handleQueueGenerateThumbnails({ force }: IBaseJob) {
 | 
					  async handleQueueGenerateThumbnails({ force }: IBaseJob) {
 | 
				
			||||||
@ -108,29 +118,9 @@ export class MediaService {
 | 
				
			|||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (asset.resizePath) {
 | 
					    await this.storageCore.moveAssetFile(asset, AssetPathType.JPEG_THUMBNAIL);
 | 
				
			||||||
      const resizePath = this.ensureThumbnailPath(asset, 'jpeg');
 | 
					    await this.storageCore.moveAssetFile(asset, AssetPathType.WEBP_THUMBNAIL);
 | 
				
			||||||
      if (asset.resizePath !== resizePath) {
 | 
					    await this.storageCore.moveAssetFile(asset, AssetPathType.ENCODED_VIDEO);
 | 
				
			||||||
        await this.storageRepository.moveFile(asset.resizePath, resizePath);
 | 
					 | 
				
			||||||
        await this.assetRepository.save({ id: asset.id, resizePath });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (asset.webpPath) {
 | 
					 | 
				
			||||||
      const webpPath = this.ensureThumbnailPath(asset, 'webp');
 | 
					 | 
				
			||||||
      if (asset.webpPath !== webpPath) {
 | 
					 | 
				
			||||||
        await this.storageRepository.moveFile(asset.webpPath, webpPath);
 | 
					 | 
				
			||||||
        await this.assetRepository.save({ id: asset.id, webpPath });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (asset.encodedVideoPath) {
 | 
					 | 
				
			||||||
      const encodedVideoPath = this.ensureEncodedVideoPath(asset, 'mp4');
 | 
					 | 
				
			||||||
      if (asset.encodedVideoPath !== encodedVideoPath) {
 | 
					 | 
				
			||||||
        await this.storageRepository.moveFile(asset.encodedVideoPath, encodedVideoPath);
 | 
					 | 
				
			||||||
        await this.assetRepository.save({ id: asset.id, encodedVideoPath });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -146,37 +136,21 @@ export class MediaService {
 | 
				
			|||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
 | 
					  private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
 | 
				
			||||||
    let path;
 | 
					    const { thumbnail, ffmpeg } = await this.configCore.getConfig();
 | 
				
			||||||
 | 
					    const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
 | 
				
			||||||
 | 
					    const path =
 | 
				
			||||||
 | 
					      format === 'jpeg' ? this.storageCore.getLargeThumbnailPath(asset) : this.storageCore.getSmallThumbnailPath(asset);
 | 
				
			||||||
 | 
					    this.storageCore.ensureFolders(path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    switch (asset.type) {
 | 
					    switch (asset.type) {
 | 
				
			||||||
      case AssetType.IMAGE:
 | 
					      case AssetType.IMAGE:
 | 
				
			||||||
        path = await this.generateImageThumbnail(asset, format);
 | 
					 | 
				
			||||||
        break;
 | 
					 | 
				
			||||||
      case AssetType.VIDEO:
 | 
					 | 
				
			||||||
        path = await this.generateVideoThumbnail(asset, format);
 | 
					 | 
				
			||||||
        break;
 | 
					 | 
				
			||||||
      default:
 | 
					 | 
				
			||||||
        throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    this.logger.log(
 | 
					 | 
				
			||||||
      `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    return path;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async generateImageThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
 | 
					 | 
				
			||||||
    const { thumbnail } = await this.configCore.getConfig();
 | 
					 | 
				
			||||||
    const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
 | 
					 | 
				
			||||||
    const path = this.ensureThumbnailPath(asset, format);
 | 
					 | 
				
			||||||
        const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
 | 
					        const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
 | 
				
			||||||
        const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
 | 
					        const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
 | 
				
			||||||
        await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
 | 
					        await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
 | 
				
			||||||
    return path;
 | 
					        break;
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async generateVideoThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
 | 
					      case AssetType.VIDEO:
 | 
				
			||||||
    const { ffmpeg, thumbnail } = await this.configCore.getConfig();
 | 
					 | 
				
			||||||
    const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
 | 
					 | 
				
			||||||
        const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
 | 
					        const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
 | 
				
			||||||
        const mainVideoStream = this.getMainStream(videoStreams);
 | 
					        const mainVideoStream = this.getMainStream(videoStreams);
 | 
				
			||||||
        if (!mainVideoStream) {
 | 
					        if (!mainVideoStream) {
 | 
				
			||||||
@ -184,10 +158,17 @@ export class MediaService {
 | 
				
			|||||||
          return;
 | 
					          return;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        const mainAudioStream = this.getMainStream(audioStreams);
 | 
					        const mainAudioStream = this.getMainStream(audioStreams);
 | 
				
			||||||
    const path = this.ensureThumbnailPath(asset, format);
 | 
					 | 
				
			||||||
        const config = { ...ffmpeg, targetResolution: size.toString() };
 | 
					        const config = { ...ffmpeg, targetResolution: size.toString() };
 | 
				
			||||||
        const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
 | 
					        const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
 | 
				
			||||||
        await this.mediaRepository.transcode(asset.originalPath, path, options);
 | 
					        await this.mediaRepository.transcode(asset.originalPath, path, options);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.logger.log(
 | 
				
			||||||
 | 
					      `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    return path;
 | 
					    return path;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -239,7 +220,8 @@ export class MediaService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const input = asset.originalPath;
 | 
					    const input = asset.originalPath;
 | 
				
			||||||
    const output = this.ensureEncodedVideoPath(asset, 'mp4');
 | 
					    const output = this.storageCore.getEncodedVideoPath(asset);
 | 
				
			||||||
 | 
					    this.storageCore.ensureFolders(output);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
 | 
					    const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
 | 
				
			||||||
    const mainVideoStream = this.getMainStream(videoStreams);
 | 
					    const mainVideoStream = this.getMainStream(videoStreams);
 | 
				
			||||||
@ -382,14 +364,6 @@ export class MediaService {
 | 
				
			|||||||
    return handler;
 | 
					    return handler;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ensureThumbnailPath(asset: AssetEntity, extension: string): string {
 | 
					 | 
				
			||||||
    return this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.${extension}`);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  ensureEncodedVideoPath(asset: AssetEntity, extension: string): string {
 | 
					 | 
				
			||||||
    return this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.${extension}`);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  isSRGB(asset: AssetEntity): boolean {
 | 
					  isSRGB(asset: AssetEntity): boolean {
 | 
				
			||||||
    const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
 | 
					    const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
 | 
				
			||||||
    if (colorspace || profileDescription) {
 | 
					    if (colorspace || profileDescription) {
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,8 @@ import {
 | 
				
			|||||||
  newCryptoRepositoryMock,
 | 
					  newCryptoRepositoryMock,
 | 
				
			||||||
  newJobRepositoryMock,
 | 
					  newJobRepositoryMock,
 | 
				
			||||||
  newMetadataRepositoryMock,
 | 
					  newMetadataRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
  newSystemConfigRepositoryMock,
 | 
					  newSystemConfigRepositoryMock,
 | 
				
			||||||
} from '@test';
 | 
					} from '@test';
 | 
				
			||||||
@ -19,6 +21,8 @@ import {
 | 
				
			|||||||
  ICryptoRepository,
 | 
					  ICryptoRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  IMetadataRepository,
 | 
					  IMetadataRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  ISystemConfigRepository,
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
  ImmichTags,
 | 
					  ImmichTags,
 | 
				
			||||||
@ -34,6 +38,8 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
  let cryptoRepository: jest.Mocked<ICryptoRepository>;
 | 
					  let cryptoRepository: jest.Mocked<ICryptoRepository>;
 | 
				
			||||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
					  let jobMock: jest.Mocked<IJobRepository>;
 | 
				
			||||||
  let metadataMock: jest.Mocked<IMetadataRepository>;
 | 
					  let metadataMock: jest.Mocked<IMetadataRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
  let sut: MetadataService;
 | 
					  let sut: MetadataService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -44,9 +50,21 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
    cryptoRepository = newCryptoRepositoryMock();
 | 
					    cryptoRepository = newCryptoRepositoryMock();
 | 
				
			||||||
    jobMock = newJobRepositoryMock();
 | 
					    jobMock = newJobRepositoryMock();
 | 
				
			||||||
    metadataMock = newMetadataRepositoryMock();
 | 
					    metadataMock = newMetadataRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new MetadataService(albumMock, assetMock, cryptoRepository, jobMock, metadataMock, storageMock, configMock);
 | 
					    sut = new MetadataService(
 | 
				
			||||||
 | 
					      albumMock,
 | 
				
			||||||
 | 
					      assetMock,
 | 
				
			||||||
 | 
					      cryptoRepository,
 | 
				
			||||||
 | 
					      jobMock,
 | 
				
			||||||
 | 
					      metadataMock,
 | 
				
			||||||
 | 
					      storageMock,
 | 
				
			||||||
 | 
					      configMock,
 | 
				
			||||||
 | 
					      moveMock,
 | 
				
			||||||
 | 
					      personMock,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should be defined', () => {
 | 
					  it('should be defined', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -12,13 +12,15 @@ import {
 | 
				
			|||||||
  ICryptoRepository,
 | 
					  ICryptoRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  IMetadataRepository,
 | 
					  IMetadataRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  ISystemConfigRepository,
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
  ImmichTags,
 | 
					  ImmichTags,
 | 
				
			||||||
  WithProperty,
 | 
					  WithProperty,
 | 
				
			||||||
  WithoutProperty,
 | 
					  WithoutProperty,
 | 
				
			||||||
} from '../repositories';
 | 
					} from '../repositories';
 | 
				
			||||||
import { StorageCore, StorageFolder } from '../storage';
 | 
					import { StorageCore } from '../storage';
 | 
				
			||||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
 | 
					import { FeatureFlag, SystemConfigCore } from '../system-config';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface DirectoryItem {
 | 
					interface DirectoryItem {
 | 
				
			||||||
@ -73,9 +75,11 @@ export class MetadataService {
 | 
				
			|||||||
    @Inject(IMetadataRepository) private repository: IMetadataRepository,
 | 
					    @Inject(IMetadataRepository) private repository: IMetadataRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					 | 
				
			||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
    this.configCore.config$.subscribe(() => this.init());
 | 
					    this.configCore.config$.subscribe(() => this.init());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -296,7 +300,7 @@ export class MetadataService {
 | 
				
			|||||||
          localDateTime: createdAt,
 | 
					          localDateTime: createdAt,
 | 
				
			||||||
          checksum,
 | 
					          checksum,
 | 
				
			||||||
          ownerId: asset.ownerId,
 | 
					          ownerId: asset.ownerId,
 | 
				
			||||||
          originalPath: this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`),
 | 
					          originalPath: this.storageCore.getAndroidMotionPath(asset),
 | 
				
			||||||
          originalFileName: asset.originalFileName,
 | 
					          originalFileName: asset.originalFileName,
 | 
				
			||||||
          isVisible: false,
 | 
					          isVisible: false,
 | 
				
			||||||
          isReadOnly: false,
 | 
					          isReadOnly: false,
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ import {
 | 
				
			|||||||
  newJobRepositoryMock,
 | 
					  newJobRepositoryMock,
 | 
				
			||||||
  newMachineLearningRepositoryMock,
 | 
					  newMachineLearningRepositoryMock,
 | 
				
			||||||
  newMediaRepositoryMock,
 | 
					  newMediaRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
  newPersonRepositoryMock,
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
  newSearchRepositoryMock,
 | 
					  newSearchRepositoryMock,
 | 
				
			||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
@ -23,6 +24,7 @@ import {
 | 
				
			|||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  IMachineLearningRepository,
 | 
					  IMachineLearningRepository,
 | 
				
			||||||
  IMediaRepository,
 | 
					  IMediaRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
  IPersonRepository,
 | 
					  IPersonRepository,
 | 
				
			||||||
  ISearchRepository,
 | 
					  ISearchRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
@ -91,6 +93,7 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
					  let jobMock: jest.Mocked<IJobRepository>;
 | 
				
			||||||
  let machineLearningMock: jest.Mocked<IMachineLearningRepository>;
 | 
					  let machineLearningMock: jest.Mocked<IMachineLearningRepository>;
 | 
				
			||||||
  let mediaMock: jest.Mocked<IMediaRepository>;
 | 
					  let mediaMock: jest.Mocked<IMediaRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
  let personMock: jest.Mocked<IPersonRepository>;
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let searchMock: jest.Mocked<ISearchRepository>;
 | 
					  let searchMock: jest.Mocked<ISearchRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
@ -102,6 +105,7 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
    configMock = newSystemConfigRepositoryMock();
 | 
					    configMock = newSystemConfigRepositoryMock();
 | 
				
			||||||
    jobMock = newJobRepositoryMock();
 | 
					    jobMock = newJobRepositoryMock();
 | 
				
			||||||
    machineLearningMock = newMachineLearningRepositoryMock();
 | 
					    machineLearningMock = newMachineLearningRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
    mediaMock = newMediaRepositoryMock();
 | 
					    mediaMock = newMediaRepositoryMock();
 | 
				
			||||||
    personMock = newPersonRepositoryMock();
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    searchMock = newSearchRepositoryMock();
 | 
					    searchMock = newSearchRepositoryMock();
 | 
				
			||||||
@ -110,6 +114,7 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
      accessMock,
 | 
					      accessMock,
 | 
				
			||||||
      assetMock,
 | 
					      assetMock,
 | 
				
			||||||
      machineLearningMock,
 | 
					      machineLearningMock,
 | 
				
			||||||
 | 
					      moveMock,
 | 
				
			||||||
      mediaMock,
 | 
					      mediaMock,
 | 
				
			||||||
      personMock,
 | 
					      personMock,
 | 
				
			||||||
      searchMock,
 | 
					      searchMock,
 | 
				
			||||||
@ -547,19 +552,19 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
    it('should generate a thumbnail', async () => {
 | 
					    it('should generate a thumbnail', async () => {
 | 
				
			||||||
      personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
 | 
					      personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
 | 
				
			||||||
      personMock.getFacesByIds.mockResolvedValue([faceStub.middle]);
 | 
					      personMock.getFacesByIds.mockResolvedValue([faceStub.middle]);
 | 
				
			||||||
      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 | 
					      assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
 | 
					      await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]);
 | 
				
			||||||
      expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/pe/rs');
 | 
					      expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
 | 
				
			||||||
      expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
 | 
					      expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', {
 | 
				
			||||||
        left: 95,
 | 
					        left: 95,
 | 
				
			||||||
        top: 95,
 | 
					        top: 95,
 | 
				
			||||||
        width: 110,
 | 
					        width: 110,
 | 
				
			||||||
        height: 110,
 | 
					        height: 110,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', {
 | 
					      expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
 | 
				
			||||||
        format: 'jpeg',
 | 
					        format: 'jpeg',
 | 
				
			||||||
        size: 250,
 | 
					        size: 250,
 | 
				
			||||||
        quality: 80,
 | 
					        quality: 80,
 | 
				
			||||||
@ -567,7 +572,7 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
      expect(personMock.update).toHaveBeenCalledWith({
 | 
					      expect(personMock.update).toHaveBeenCalledWith({
 | 
				
			||||||
        id: 'person-1',
 | 
					        id: 'person-1',
 | 
				
			||||||
        thumbnailPath: 'upload/thumbs/user-id/pe/rs/person-1.jpeg',
 | 
					        thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -584,7 +589,7 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
        width: 510,
 | 
					        width: 510,
 | 
				
			||||||
        height: 510,
 | 
					        height: 510,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', {
 | 
					      expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
 | 
				
			||||||
        format: 'jpeg',
 | 
					        format: 'jpeg',
 | 
				
			||||||
        size: 250,
 | 
					        size: 250,
 | 
				
			||||||
        quality: 80,
 | 
					        quality: 80,
 | 
				
			||||||
@ -595,17 +600,17 @@ describe(PersonService.name, () => {
 | 
				
			|||||||
    it('should generate a thumbnail without overflowing', async () => {
 | 
					    it('should generate a thumbnail without overflowing', async () => {
 | 
				
			||||||
      personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
 | 
					      personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
 | 
				
			||||||
      personMock.getFacesByIds.mockResolvedValue([faceStub.end]);
 | 
					      personMock.getFacesByIds.mockResolvedValue([faceStub.end]);
 | 
				
			||||||
      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 | 
					      assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
 | 
					      await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
 | 
					      expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', {
 | 
				
			||||||
        left: 297,
 | 
					        left: 297,
 | 
				
			||||||
        top: 297,
 | 
					        top: 297,
 | 
				
			||||||
        width: 202,
 | 
					        width: 202,
 | 
				
			||||||
        height: 202,
 | 
					        height: 202,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/pe/rs/person-1.jpeg', {
 | 
					      expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
 | 
				
			||||||
        format: 'jpeg',
 | 
					        format: 'jpeg',
 | 
				
			||||||
        size: 250,
 | 
					        size: 250,
 | 
				
			||||||
        quality: 80,
 | 
					        quality: 80,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
import { PersonEntity } from '@app/infra/entities';
 | 
					import { PersonEntity } from '@app/infra/entities';
 | 
				
			||||||
 | 
					import { PersonPathType } from '@app/infra/entities/move.entity';
 | 
				
			||||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 | 
					import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 | 
				
			||||||
import { AccessCore, Permission } from '../access';
 | 
					import { AccessCore, Permission } from '../access';
 | 
				
			||||||
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
 | 
					import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
 | 
				
			||||||
@ -15,6 +16,7 @@ import {
 | 
				
			|||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  IMachineLearningRepository,
 | 
					  IMachineLearningRepository,
 | 
				
			||||||
  IMediaRepository,
 | 
					  IMediaRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
  IPersonRepository,
 | 
					  IPersonRepository,
 | 
				
			||||||
  ISearchRepository,
 | 
					  ISearchRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
@ -23,7 +25,7 @@ import {
 | 
				
			|||||||
  UpdateFacesData,
 | 
					  UpdateFacesData,
 | 
				
			||||||
  WithoutProperty,
 | 
					  WithoutProperty,
 | 
				
			||||||
} from '../repositories';
 | 
					} from '../repositories';
 | 
				
			||||||
import { StorageCore, StorageFolder } from '../storage';
 | 
					import { StorageCore } from '../storage';
 | 
				
			||||||
import { SystemConfigCore } from '../system-config';
 | 
					import { SystemConfigCore } from '../system-config';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  MergePersonDto,
 | 
					  MergePersonDto,
 | 
				
			||||||
@ -46,6 +48,7 @@ export class PersonService {
 | 
				
			|||||||
    @Inject(IAccessRepository) accessRepository: IAccessRepository,
 | 
					    @Inject(IAccessRepository) accessRepository: IAccessRepository,
 | 
				
			||||||
    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
					    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
				
			||||||
    @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
 | 
					    @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
    @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
 | 
					    @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
 | 
				
			||||||
    @Inject(IPersonRepository) private repository: IPersonRepository,
 | 
					    @Inject(IPersonRepository) private repository: IPersonRepository,
 | 
				
			||||||
    @Inject(ISearchRepository) private searchRepository: ISearchRepository,
 | 
					    @Inject(ISearchRepository) private searchRepository: ISearchRepository,
 | 
				
			||||||
@ -54,8 +57,8 @@ export class PersonService {
 | 
				
			|||||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
					    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.access = new AccessCore(accessRepository);
 | 
					    this.access = new AccessCore(accessRepository);
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					 | 
				
			||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, repository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
 | 
					  async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
 | 
				
			||||||
@ -268,11 +271,7 @@ export class PersonService {
 | 
				
			|||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const path = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, person.ownerId, `${id}.jpeg`);
 | 
					    await this.storageCore.movePersonFile(person, PersonPathType.FACE);
 | 
				
			||||||
    if (person.thumbnailPath && person.thumbnailPath !== path) {
 | 
					 | 
				
			||||||
      await this.storageRepository.moveFile(person.thumbnailPath, path);
 | 
					 | 
				
			||||||
      await this.repository.update({ id, thumbnailPath: path });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -310,8 +309,8 @@ export class PersonService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.logger.verbose(`Cropping face for person: ${personId}`);
 | 
					    this.logger.verbose(`Cropping face for person: ${personId}`);
 | 
				
			||||||
 | 
					    const thumbnailPath = this.storageCore.getPersonThumbnailPath(person);
 | 
				
			||||||
    const thumbnailPath = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${personId}.jpeg`);
 | 
					    this.storageCore.ensureFolders(thumbnailPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const halfWidth = (x2 - x1) / 2;
 | 
					    const halfWidth = (x2 - x1) / 2;
 | 
				
			||||||
    const halfHeight = (y2 - y1) / 2;
 | 
					    const halfHeight = (y2 - y1) / 2;
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ export * from './library.repository';
 | 
				
			|||||||
export * from './machine-learning.repository';
 | 
					export * from './machine-learning.repository';
 | 
				
			||||||
export * from './media.repository';
 | 
					export * from './media.repository';
 | 
				
			||||||
export * from './metadata.repository';
 | 
					export * from './metadata.repository';
 | 
				
			||||||
 | 
					export * from './move.repository';
 | 
				
			||||||
export * from './partner.repository';
 | 
					export * from './partner.repository';
 | 
				
			||||||
export * from './person.repository';
 | 
					export * from './person.repository';
 | 
				
			||||||
export * from './search.repository';
 | 
					export * from './search.repository';
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										12
									
								
								server/src/domain/repositories/move.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/src/domain/repositories/move.repository.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					import { MoveEntity, PathType } from '@app/infra/entities';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const IMoveRepository = 'IMoveRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type MoveCreate = Pick<MoveEntity, 'oldPath' | 'newPath' | 'entityId' | 'pathType'> & Partial<MoveEntity>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface IMoveRepository {
 | 
				
			||||||
 | 
					  create(entity: MoveCreate): Promise<MoveEntity>;
 | 
				
			||||||
 | 
					  getByEntity(entityId: string, pathType: PathType): Promise<MoveEntity | null>;
 | 
				
			||||||
 | 
					  update(entity: Partial<MoveEntity>): Promise<MoveEntity>;
 | 
				
			||||||
 | 
					  delete(move: MoveEntity): Promise<MoveEntity>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,20 +1,40 @@
 | 
				
			|||||||
import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test';
 | 
					import {
 | 
				
			||||||
 | 
					  newAssetRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
 | 
					  newSystemConfigRepositoryMock,
 | 
				
			||||||
 | 
					  newUserRepositoryMock,
 | 
				
			||||||
 | 
					} from '@test';
 | 
				
			||||||
import { serverVersion } from '../domain.constant';
 | 
					import { serverVersion } from '../domain.constant';
 | 
				
			||||||
import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
 | 
					import {
 | 
				
			||||||
 | 
					  IAssetRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
 | 
					  IStorageRepository,
 | 
				
			||||||
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
 | 
					  IUserRepository,
 | 
				
			||||||
 | 
					} from '../repositories';
 | 
				
			||||||
import { ServerInfoService } from './server-info.service';
 | 
					import { ServerInfoService } from './server-info.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe(ServerInfoService.name, () => {
 | 
					describe(ServerInfoService.name, () => {
 | 
				
			||||||
  let sut: ServerInfoService;
 | 
					  let sut: ServerInfoService;
 | 
				
			||||||
 | 
					  let assetMock: jest.Mocked<IAssetRepository>;
 | 
				
			||||||
  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
					  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
  let userMock: jest.Mocked<IUserRepository>;
 | 
					  let userMock: jest.Mocked<IUserRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  beforeEach(() => {
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    assetMock = newAssetRepositoryMock();
 | 
				
			||||||
    configMock = newSystemConfigRepositoryMock();
 | 
					    configMock = newSystemConfigRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
    userMock = newUserRepositoryMock();
 | 
					    userMock = newUserRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new ServerInfoService(configMock, userMock, storageMock);
 | 
					    sut = new ServerInfoService(assetMock, configMock, moveMock, personMock, userMock, storageMock);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should work', () => {
 | 
					  it('should work', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,15 @@
 | 
				
			|||||||
import { Inject, Injectable } from '@nestjs/common';
 | 
					import { Inject, Injectable } from '@nestjs/common';
 | 
				
			||||||
import { mimeTypes, serverVersion } from '../domain.constant';
 | 
					import { mimeTypes, serverVersion } from '../domain.constant';
 | 
				
			||||||
import { asHumanReadable } from '../domain.util';
 | 
					import { asHumanReadable } from '../domain.util';
 | 
				
			||||||
import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories';
 | 
					import {
 | 
				
			||||||
 | 
					  IAssetRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
 | 
					  IStorageRepository,
 | 
				
			||||||
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
 | 
					  IUserRepository,
 | 
				
			||||||
 | 
					  UserStatsQueryResponse,
 | 
				
			||||||
 | 
					} from '../repositories';
 | 
				
			||||||
import { StorageCore, StorageFolder } from '../storage';
 | 
					import { StorageCore, StorageFolder } from '../storage';
 | 
				
			||||||
import { SystemConfigCore } from '../system-config';
 | 
					import { SystemConfigCore } from '../system-config';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -20,12 +28,15 @@ export class ServerInfoService {
 | 
				
			|||||||
  private storageCore: StorageCore;
 | 
					  private storageCore: StorageCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
 | 
					    @Inject(IAssetRepository) assetRepository: IAssetRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
					    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getInfo(): Promise<ServerInfoResponseDto> {
 | 
					  async getInfo(): Promise<ServerInfoResponseDto> {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,23 @@
 | 
				
			|||||||
 | 
					import { AssetPathType } from '@app/infra/entities';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  assetStub,
 | 
					  assetStub,
 | 
				
			||||||
  newAssetRepositoryMock,
 | 
					  newAssetRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
  newSystemConfigRepositoryMock,
 | 
					  newSystemConfigRepositoryMock,
 | 
				
			||||||
  newUserRepositoryMock,
 | 
					  newUserRepositoryMock,
 | 
				
			||||||
  userStub,
 | 
					  userStub,
 | 
				
			||||||
} from '@test';
 | 
					} from '@test';
 | 
				
			||||||
import { when } from 'jest-when';
 | 
					import { when } from 'jest-when';
 | 
				
			||||||
import { IAssetRepository, IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
 | 
					import {
 | 
				
			||||||
 | 
					  IAssetRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
 | 
					  IStorageRepository,
 | 
				
			||||||
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
 | 
					  IUserRepository,
 | 
				
			||||||
 | 
					} from '../repositories';
 | 
				
			||||||
import { defaults } from '../system-config/system-config.core';
 | 
					import { defaults } from '../system-config/system-config.core';
 | 
				
			||||||
import { StorageTemplateService } from './storage-template.service';
 | 
					import { StorageTemplateService } from './storage-template.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -15,6 +25,8 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
  let sut: StorageTemplateService;
 | 
					  let sut: StorageTemplateService;
 | 
				
			||||||
  let assetMock: jest.Mocked<IAssetRepository>;
 | 
					  let assetMock: jest.Mocked<IAssetRepository>;
 | 
				
			||||||
  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
					  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
  let userMock: jest.Mocked<IUserRepository>;
 | 
					  let userMock: jest.Mocked<IUserRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -25,10 +37,12 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
  beforeEach(async () => {
 | 
					  beforeEach(async () => {
 | 
				
			||||||
    assetMock = newAssetRepositoryMock();
 | 
					    assetMock = newAssetRepositoryMock();
 | 
				
			||||||
    configMock = newSystemConfigRepositoryMock();
 | 
					    configMock = newSystemConfigRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
    userMock = newUserRepositoryMock();
 | 
					    userMock = newUserRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
 | 
					    sut = new StorageTemplateService(assetMock, configMock, defaults, moveMock, personMock, storageMock, userMock);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('handleMigrationSingle', () => {
 | 
					  describe('handleMigrationSingle', () => {
 | 
				
			||||||
@ -86,6 +100,13 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
					      assetMock.save.mockResolvedValue(assetStub.image);
 | 
				
			||||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
					      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
				
			||||||
 | 
					      moveMock.create.mockResolvedValue({
 | 
				
			||||||
 | 
					        id: '123',
 | 
				
			||||||
 | 
					        entityId: assetStub.image.id,
 | 
				
			||||||
 | 
					        pathType: AssetPathType.ORIGINAL,
 | 
				
			||||||
 | 
					        oldPath: assetStub.image.originalPath,
 | 
				
			||||||
 | 
					        newPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(storageMock.checkFileExists)
 | 
					      when(storageMock.checkFileExists)
 | 
				
			||||||
        .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
 | 
					        .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
 | 
				
			||||||
@ -153,6 +174,13 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
					      assetMock.save.mockResolvedValue(assetStub.image);
 | 
				
			||||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
					      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
				
			||||||
 | 
					      moveMock.create.mockResolvedValue({
 | 
				
			||||||
 | 
					        id: '123',
 | 
				
			||||||
 | 
					        entityId: assetStub.image.id,
 | 
				
			||||||
 | 
					        pathType: AssetPathType.ORIGINAL,
 | 
				
			||||||
 | 
					        oldPath: assetStub.image.originalPath,
 | 
				
			||||||
 | 
					        newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await sut.handleMigration();
 | 
					      await sut.handleMigration();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -174,6 +202,13 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
					      assetMock.save.mockResolvedValue(assetStub.image);
 | 
				
			||||||
      userMock.getList.mockResolvedValue([userStub.storageLabel]);
 | 
					      userMock.getList.mockResolvedValue([userStub.storageLabel]);
 | 
				
			||||||
 | 
					      moveMock.create.mockResolvedValue({
 | 
				
			||||||
 | 
					        id: '123',
 | 
				
			||||||
 | 
					        entityId: assetStub.image.id,
 | 
				
			||||||
 | 
					        pathType: AssetPathType.ORIGINAL,
 | 
				
			||||||
 | 
					        oldPath: assetStub.image.originalPath,
 | 
				
			||||||
 | 
					        newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await sut.handleMigration();
 | 
					      await sut.handleMigration();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -194,6 +229,13 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
        hasNextPage: false,
 | 
					        hasNextPage: false,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
 | 
					      storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
 | 
				
			||||||
 | 
					      moveMock.create.mockResolvedValue({
 | 
				
			||||||
 | 
					        id: 'move-123',
 | 
				
			||||||
 | 
					        entityId: '123',
 | 
				
			||||||
 | 
					        pathType: AssetPathType.ORIGINAL,
 | 
				
			||||||
 | 
					        oldPath: assetStub.image.originalPath,
 | 
				
			||||||
 | 
					        newPath: '',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
					      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await sut.handleMigration();
 | 
					      await sut.handleMigration();
 | 
				
			||||||
@ -206,27 +248,6 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
					      expect(assetMock.save).not.toHaveBeenCalled();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should move the asset back if the database fails', async () => {
 | 
					 | 
				
			||||||
      assetMock.getAll.mockResolvedValue({
 | 
					 | 
				
			||||||
        items: [assetStub.image],
 | 
					 | 
				
			||||||
        hasNextPage: false,
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      assetMock.save.mockRejectedValue('Connection Error!');
 | 
					 | 
				
			||||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await sut.handleMigration();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
					 | 
				
			||||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
					 | 
				
			||||||
        id: assetStub.image.id,
 | 
					 | 
				
			||||||
        originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      expect(storageMock.moveFile.mock.calls).toEqual([
 | 
					 | 
				
			||||||
        ['/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'],
 | 
					 | 
				
			||||||
        ['upload/library/user-id/2023/2023-02-23/asset-id.jpg', '/original/path.jpg'],
 | 
					 | 
				
			||||||
      ]);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    it('should not move read-only asset', async () => {
 | 
					    it('should not move read-only asset', async () => {
 | 
				
			||||||
      assetMock.getAll.mockResolvedValue({
 | 
					      assetMock.getAll.mockResolvedValue({
 | 
				
			||||||
        items: [
 | 
					        items: [
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { AssetEntity, AssetType, SystemConfig } from '@app/infra/entities';
 | 
					import { AssetEntity, AssetPathType, AssetType, SystemConfig } from '@app/infra/entities';
 | 
				
			||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
					import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
				
			||||||
import handlebar from 'handlebars';
 | 
					import handlebar from 'handlebars';
 | 
				
			||||||
import * as luxon from 'luxon';
 | 
					import * as luxon from 'luxon';
 | 
				
			||||||
@ -6,7 +6,14 @@ import path from 'node:path';
 | 
				
			|||||||
import sanitize from 'sanitize-filename';
 | 
					import sanitize from 'sanitize-filename';
 | 
				
			||||||
import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
 | 
					import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
 | 
				
			||||||
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
 | 
					import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
 | 
				
			||||||
import { IAssetRepository, IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
 | 
					import {
 | 
				
			||||||
 | 
					  IAssetRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
 | 
					  IStorageRepository,
 | 
				
			||||||
 | 
					  ISystemConfigRepository,
 | 
				
			||||||
 | 
					  IUserRepository,
 | 
				
			||||||
 | 
					} from '../repositories';
 | 
				
			||||||
import { StorageCore, StorageFolder } from '../storage';
 | 
					import { StorageCore, StorageFolder } from '../storage';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  INITIAL_SYSTEM_CONFIG,
 | 
					  INITIAL_SYSTEM_CONFIG,
 | 
				
			||||||
@ -36,6 +43,8 @@ export class StorageTemplateService {
 | 
				
			|||||||
    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
					    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
    @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
 | 
					    @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
					    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
@ -43,7 +52,7 @@ export class StorageTemplateService {
 | 
				
			|||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
    this.configCore.addValidator((config) => this.validate(config));
 | 
					    this.configCore.addValidator((config) => this.validate(config));
 | 
				
			||||||
    this.configCore.config$.subscribe((config) => this.onConfig(config));
 | 
					    this.configCore.config$.subscribe((config) => this.onConfig(config));
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async handleMigrationSingle({ id }: IEntityJob) {
 | 
					  async handleMigrationSingle({ id }: IEntityJob) {
 | 
				
			||||||
@ -90,52 +99,30 @@ export class StorageTemplateService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
 | 
					  async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
 | 
				
			||||||
    if (asset.isReadOnly || asset.isExternal) {
 | 
					    if (asset.isReadOnly || asset.isExternal || this.storageCore.isAndroidMotionPath(asset.originalPath)) {
 | 
				
			||||||
      // External assets are not affected by storage template
 | 
					      // External assets are not affected by storage template
 | 
				
			||||||
      // TODO: shouldn't this only apply to external assets?
 | 
					      // TODO: shouldn't this only apply to external assets?
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const destination = await this.getTemplatePath(asset, metadata);
 | 
					    const { id, sidecarPath, originalPath } = asset;
 | 
				
			||||||
    if (asset.originalPath !== destination) {
 | 
					    const oldPath = originalPath;
 | 
				
			||||||
      const source = asset.originalPath;
 | 
					    const newPath = await this.getTemplatePath(asset, metadata);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      let sidecarMoved = false;
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
        await this.storageRepository.moveFile(asset.originalPath, destination);
 | 
					      await this.storageCore.moveFile({ entityId: id, pathType: AssetPathType.ORIGINAL, oldPath, newPath });
 | 
				
			||||||
 | 
					      if (sidecarPath) {
 | 
				
			||||||
        let sidecarDestination;
 | 
					        await this.storageCore.moveFile({
 | 
				
			||||||
        try {
 | 
					          entityId: id,
 | 
				
			||||||
          if (asset.sidecarPath) {
 | 
					          pathType: AssetPathType.SIDECAR,
 | 
				
			||||||
            sidecarDestination = `${destination}.xmp`;
 | 
					          oldPath: sidecarPath,
 | 
				
			||||||
            await this.storageRepository.moveFile(asset.sidecarPath, sidecarDestination);
 | 
					          newPath: `${newPath}.xmp`,
 | 
				
			||||||
            sidecarMoved = true;
 | 
					        });
 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          await this.assetRepository.save({ id: asset.id, originalPath: destination, sidecarPath: sidecarDestination });
 | 
					 | 
				
			||||||
          asset.originalPath = destination;
 | 
					 | 
				
			||||||
          asset.sidecarPath = sidecarDestination || null;
 | 
					 | 
				
			||||||
        } catch (error: any) {
 | 
					 | 
				
			||||||
          this.logger.warn(
 | 
					 | 
				
			||||||
            `Unable to save new originalPath to database, undoing move for path ${asset.originalPath} - filename ${asset.originalFileName} - id ${asset.id}`,
 | 
					 | 
				
			||||||
            error?.stack,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          // Either sidecar move failed or the save failed. Either way, move media back
 | 
					 | 
				
			||||||
          await this.storageRepository.moveFile(destination, source);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          if (asset.sidecarPath && sidecarDestination && sidecarMoved) {
 | 
					 | 
				
			||||||
            // If the sidecar was moved, that means the saved failed. So move both the sidecar and the
 | 
					 | 
				
			||||||
            // media back into their original positions
 | 
					 | 
				
			||||||
            await this.storageRepository.moveFile(sidecarDestination, asset.sidecarPath);
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } catch (error: any) {
 | 
					    } catch (error: any) {
 | 
				
			||||||
        this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination });
 | 
					      this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, oldPath, newPath });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
    return asset;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
 | 
					  private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
 | 
				
			||||||
    const { storageLabel, filename } = metadata;
 | 
					    const { storageLabel, filename } = metadata;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,8 @@
 | 
				
			|||||||
import { join } from 'node:path';
 | 
					import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
 | 
				
			||||||
 | 
					import { Logger } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { dirname, join } from 'node:path';
 | 
				
			||||||
import { APP_MEDIA_LOCATION } from '../domain.constant';
 | 
					import { APP_MEDIA_LOCATION } from '../domain.constant';
 | 
				
			||||||
import { IStorageRepository } from '../repositories';
 | 
					import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum StorageFolder {
 | 
					export enum StorageFolder {
 | 
				
			||||||
  ENCODED_VIDEO = 'encoded-video',
 | 
					  ENCODED_VIDEO = 'encoded-video',
 | 
				
			||||||
@ -10,13 +12,26 @@ export enum StorageFolder {
 | 
				
			|||||||
  THUMBNAILS = 'thumbs',
 | 
					  THUMBNAILS = 'thumbs',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class StorageCore {
 | 
					export interface MoveRequest {
 | 
				
			||||||
  constructor(private repository: IStorageRepository) {}
 | 
					  entityId: string;
 | 
				
			||||||
 | 
					  pathType: PathType;
 | 
				
			||||||
 | 
					  oldPath: string | null;
 | 
				
			||||||
 | 
					  newPath: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getFolderLocation(
 | 
					type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
 | 
				
			||||||
    folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
 | 
					
 | 
				
			||||||
    userId: string,
 | 
					export class StorageCore {
 | 
				
			||||||
  ) {
 | 
					  private logger = new Logger(StorageCore.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private repository: IStorageRepository,
 | 
				
			||||||
 | 
					    private assetRepository: IAssetRepository,
 | 
				
			||||||
 | 
					    private moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    private personRepository: IPersonRepository,
 | 
				
			||||||
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getFolderLocation(folder: StorageFolder, userId: string) {
 | 
				
			||||||
    return join(this.getBaseFolder(folder), userId);
 | 
					    return join(this.getBaseFolder(folder), userId);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -28,21 +43,119 @@ export class StorageCore {
 | 
				
			|||||||
    return join(APP_MEDIA_LOCATION, folder);
 | 
					    return join(APP_MEDIA_LOCATION, folder);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ensurePath(
 | 
					  getPersonThumbnailPath(person: PersonEntity) {
 | 
				
			||||||
    folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
 | 
					    return this.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
 | 
				
			||||||
    ownerId: string,
 | 
					  }
 | 
				
			||||||
    fileName: string,
 | 
					
 | 
				
			||||||
  ): string {
 | 
					  getLargeThumbnailPath(asset: AssetEntity) {
 | 
				
			||||||
    const folderPath = join(
 | 
					    return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`);
 | 
				
			||||||
      this.getFolderLocation(folder, ownerId),
 | 
					  }
 | 
				
			||||||
      fileName.substring(0, 2),
 | 
					
 | 
				
			||||||
      fileName.substring(2, 4),
 | 
					  getSmallThumbnailPath(asset: AssetEntity) {
 | 
				
			||||||
    );
 | 
					    return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`);
 | 
				
			||||||
    this.repository.mkdirSync(folderPath);
 | 
					  }
 | 
				
			||||||
    return join(folderPath, fileName);
 | 
					
 | 
				
			||||||
 | 
					  getEncodedVideoPath(asset: AssetEntity) {
 | 
				
			||||||
 | 
					    return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getAndroidMotionPath(asset: AssetEntity) {
 | 
				
			||||||
 | 
					    return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  isAndroidMotionPath(originalPath: string) {
 | 
				
			||||||
 | 
					    return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
 | 
				
			||||||
 | 
					    const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
 | 
				
			||||||
 | 
					    switch (pathType) {
 | 
				
			||||||
 | 
					      case AssetPathType.JPEG_THUMBNAIL:
 | 
				
			||||||
 | 
					        return this.moveFile({ entityId, pathType, oldPath: resizePath, newPath: this.getLargeThumbnailPath(asset) });
 | 
				
			||||||
 | 
					      case AssetPathType.WEBP_THUMBNAIL:
 | 
				
			||||||
 | 
					        return this.moveFile({ entityId, pathType, oldPath: webpPath, newPath: this.getSmallThumbnailPath(asset) });
 | 
				
			||||||
 | 
					      case AssetPathType.ENCODED_VIDEO:
 | 
				
			||||||
 | 
					        return this.moveFile({
 | 
				
			||||||
 | 
					          entityId,
 | 
				
			||||||
 | 
					          pathType,
 | 
				
			||||||
 | 
					          oldPath: encodedVideoPath,
 | 
				
			||||||
 | 
					          newPath: this.getEncodedVideoPath(asset),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
 | 
				
			||||||
 | 
					    const { id: entityId, thumbnailPath } = person;
 | 
				
			||||||
 | 
					    switch (pathType) {
 | 
				
			||||||
 | 
					      case PersonPathType.FACE:
 | 
				
			||||||
 | 
					        await this.moveFile({
 | 
				
			||||||
 | 
					          entityId,
 | 
				
			||||||
 | 
					          pathType,
 | 
				
			||||||
 | 
					          oldPath: thumbnailPath,
 | 
				
			||||||
 | 
					          newPath: this.getPersonThumbnailPath(person),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async moveFile(request: MoveRequest) {
 | 
				
			||||||
 | 
					    const { entityId, pathType, oldPath, newPath } = request;
 | 
				
			||||||
 | 
					    if (!oldPath || oldPath === newPath) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.ensureFolders(newPath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let move = await this.moveRepository.getByEntity(entityId, pathType);
 | 
				
			||||||
 | 
					    if (move) {
 | 
				
			||||||
 | 
					      this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
 | 
				
			||||||
 | 
					      const oldPathExists = await this.repository.checkFileExists(move.oldPath);
 | 
				
			||||||
 | 
					      const newPathExists = await this.repository.checkFileExists(move.newPath);
 | 
				
			||||||
 | 
					      const actualPath = newPathExists ? move.newPath : oldPathExists ? move.oldPath : null;
 | 
				
			||||||
 | 
					      if (!actualPath) {
 | 
				
			||||||
 | 
					        this.logger.warn('Unable to complete move. File does not exist at either location.');
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.logger.log(`Found file at ${actualPath === move.oldPath ? 'old' : 'new'} location`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (move.oldPath !== newPath) {
 | 
				
			||||||
 | 
					      await this.repository.moveFile(move.oldPath, newPath);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    await this.savePath(pathType, entityId, newPath);
 | 
				
			||||||
 | 
					    await this.moveRepository.delete(move);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ensureFolders(input: string) {
 | 
				
			||||||
 | 
					    this.repository.mkdirSync(dirname(input));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  removeEmptyDirs(folder: StorageFolder) {
 | 
					  removeEmptyDirs(folder: StorageFolder) {
 | 
				
			||||||
    return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
 | 
					    return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private savePath(pathType: PathType, id: string, newPath: string) {
 | 
				
			||||||
 | 
					    switch (pathType) {
 | 
				
			||||||
 | 
					      case AssetPathType.ORIGINAL:
 | 
				
			||||||
 | 
					        return this.assetRepository.save({ id, originalPath: newPath });
 | 
				
			||||||
 | 
					      case AssetPathType.JPEG_THUMBNAIL:
 | 
				
			||||||
 | 
					        return this.assetRepository.save({ id, resizePath: newPath });
 | 
				
			||||||
 | 
					      case AssetPathType.WEBP_THUMBNAIL:
 | 
				
			||||||
 | 
					        return this.assetRepository.save({ id, webpPath: newPath });
 | 
				
			||||||
 | 
					      case AssetPathType.ENCODED_VIDEO:
 | 
				
			||||||
 | 
					        return this.assetRepository.save({ id, encodedVideoPath: newPath });
 | 
				
			||||||
 | 
					      case AssetPathType.SIDECAR:
 | 
				
			||||||
 | 
					        return this.assetRepository.save({ id, sidecarPath: newPath });
 | 
				
			||||||
 | 
					      case PersonPathType.FACE:
 | 
				
			||||||
 | 
					        return this.personRepository.update({ id, thumbnailPath: newPath });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
 | 
				
			||||||
 | 
					    return join(this.getFolderLocation(folder, ownerId), filename.substring(0, 2), filename.substring(2, 4), filename);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,14 +1,25 @@
 | 
				
			|||||||
import { newStorageRepositoryMock } from '@test';
 | 
					import {
 | 
				
			||||||
import { IStorageRepository } from '../repositories';
 | 
					  newAssetRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
 | 
					} from '@test';
 | 
				
			||||||
 | 
					import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
 | 
				
			||||||
import { StorageService } from './storage.service';
 | 
					import { StorageService } from './storage.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe(StorageService.name, () => {
 | 
					describe(StorageService.name, () => {
 | 
				
			||||||
  let sut: StorageService;
 | 
					  let sut: StorageService;
 | 
				
			||||||
 | 
					  let assetMock: jest.Mocked<IAssetRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  beforeEach(async () => {
 | 
					  beforeEach(async () => {
 | 
				
			||||||
 | 
					    assetMock = newAssetRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
    sut = new StorageService(storageMock);
 | 
					    sut = new StorageService(assetMock, moveMock, personMock, storageMock);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should work', () => {
 | 
					  it('should work', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
					import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
				
			||||||
import { IDeleteFilesJob } from '../job';
 | 
					import { IDeleteFilesJob } from '../job';
 | 
				
			||||||
import { IStorageRepository } from '../repositories';
 | 
					import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
 | 
				
			||||||
import { StorageCore, StorageFolder } from './storage.core';
 | 
					import { StorageCore, StorageFolder } from './storage.core';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
@ -8,8 +8,13 @@ export class StorageService {
 | 
				
			|||||||
  private logger = new Logger(StorageService.name);
 | 
					  private logger = new Logger(StorageService.name);
 | 
				
			||||||
  private storageCore: StorageCore;
 | 
					  private storageCore: StorageCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {
 | 
					  constructor(
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					    @Inject(IAssetRepository) assetRepository: IAssetRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) private moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  init() {
 | 
					  init() {
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,8 @@ import {
 | 
				
			|||||||
  newCryptoRepositoryMock,
 | 
					  newCryptoRepositoryMock,
 | 
				
			||||||
  newJobRepositoryMock,
 | 
					  newJobRepositoryMock,
 | 
				
			||||||
  newLibraryRepositoryMock,
 | 
					  newLibraryRepositoryMock,
 | 
				
			||||||
 | 
					  newMoveRepositoryMock,
 | 
				
			||||||
 | 
					  newPersonRepositoryMock,
 | 
				
			||||||
  newStorageRepositoryMock,
 | 
					  newStorageRepositoryMock,
 | 
				
			||||||
  newUserRepositoryMock,
 | 
					  newUserRepositoryMock,
 | 
				
			||||||
  userStub,
 | 
					  userStub,
 | 
				
			||||||
@ -24,6 +26,8 @@ import {
 | 
				
			|||||||
  ICryptoRepository,
 | 
					  ICryptoRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  ILibraryRepository,
 | 
					  ILibraryRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  IUserRepository,
 | 
					  IUserRepository,
 | 
				
			||||||
} from '../repositories';
 | 
					} from '../repositories';
 | 
				
			||||||
@ -135,18 +139,32 @@ describe(UserService.name, () => {
 | 
				
			|||||||
  let assetMock: jest.Mocked<IAssetRepository>;
 | 
					  let assetMock: jest.Mocked<IAssetRepository>;
 | 
				
			||||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
					  let jobMock: jest.Mocked<IJobRepository>;
 | 
				
			||||||
  let libraryMock: jest.Mocked<ILibraryRepository>;
 | 
					  let libraryMock: jest.Mocked<ILibraryRepository>;
 | 
				
			||||||
 | 
					  let moveMock: jest.Mocked<IMoveRepository>;
 | 
				
			||||||
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
					  let storageMock: jest.Mocked<IStorageRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  beforeEach(async () => {
 | 
					  beforeEach(async () => {
 | 
				
			||||||
    cryptoRepositoryMock = newCryptoRepositoryMock();
 | 
					 | 
				
			||||||
    albumMock = newAlbumRepositoryMock();
 | 
					    albumMock = newAlbumRepositoryMock();
 | 
				
			||||||
    assetMock = newAssetRepositoryMock();
 | 
					    assetMock = newAssetRepositoryMock();
 | 
				
			||||||
 | 
					    cryptoRepositoryMock = newCryptoRepositoryMock();
 | 
				
			||||||
    jobMock = newJobRepositoryMock();
 | 
					    jobMock = newJobRepositoryMock();
 | 
				
			||||||
    libraryMock = newLibraryRepositoryMock();
 | 
					    libraryMock = newLibraryRepositoryMock();
 | 
				
			||||||
 | 
					    moveMock = newMoveRepositoryMock();
 | 
				
			||||||
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    storageMock = newStorageRepositoryMock();
 | 
					    storageMock = newStorageRepositoryMock();
 | 
				
			||||||
    userMock = newUserRepositoryMock();
 | 
					    userMock = newUserRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new UserService(userMock, cryptoRepositoryMock, libraryMock, albumMock, assetMock, jobMock, storageMock);
 | 
					    sut = new UserService(
 | 
				
			||||||
 | 
					      albumMock,
 | 
				
			||||||
 | 
					      assetMock,
 | 
				
			||||||
 | 
					      cryptoRepositoryMock,
 | 
				
			||||||
 | 
					      jobMock,
 | 
				
			||||||
 | 
					      libraryMock,
 | 
				
			||||||
 | 
					      moveMock,
 | 
				
			||||||
 | 
					      personMock,
 | 
				
			||||||
 | 
					      storageMock,
 | 
				
			||||||
 | 
					      userMock,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
 | 
					    when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
 | 
				
			||||||
    when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);
 | 
					    when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,8 @@ import {
 | 
				
			|||||||
  ICryptoRepository,
 | 
					  ICryptoRepository,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  ILibraryRepository,
 | 
					  ILibraryRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
 | 
					  IPersonRepository,
 | 
				
			||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  IUserRepository,
 | 
					  IUserRepository,
 | 
				
			||||||
} from '../repositories';
 | 
					} from '../repositories';
 | 
				
			||||||
@ -32,15 +34,17 @@ export class UserService {
 | 
				
			|||||||
  private userCore: UserCore;
 | 
					  private userCore: UserCore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
					 | 
				
			||||||
    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
 | 
					 | 
				
			||||||
    @Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
 | 
					 | 
				
			||||||
    @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
 | 
					    @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
 | 
				
			||||||
    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
					    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
				
			||||||
 | 
					    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
 | 
				
			||||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
					    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
				
			||||||
 | 
					    @Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
 | 
				
			||||||
 | 
					    @Inject(IMoveRepository) moveRepository: IMoveRepository,
 | 
				
			||||||
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
 | 
					    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.storageCore = new StorageCore(storageRepository);
 | 
					    this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
 | 
				
			||||||
    this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository);
 | 
					    this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,7 @@ import { AssetEntity } from './asset.entity';
 | 
				
			|||||||
import { AuditEntity } from './audit.entity';
 | 
					import { AuditEntity } from './audit.entity';
 | 
				
			||||||
import { ExifEntity } from './exif.entity';
 | 
					import { ExifEntity } from './exif.entity';
 | 
				
			||||||
import { LibraryEntity } from './library.entity';
 | 
					import { LibraryEntity } from './library.entity';
 | 
				
			||||||
 | 
					import { MoveEntity } from './move.entity';
 | 
				
			||||||
import { PartnerEntity } from './partner.entity';
 | 
					import { PartnerEntity } from './partner.entity';
 | 
				
			||||||
import { PersonEntity } from './person.entity';
 | 
					import { PersonEntity } from './person.entity';
 | 
				
			||||||
import { SharedLinkEntity } from './shared-link.entity';
 | 
					import { SharedLinkEntity } from './shared-link.entity';
 | 
				
			||||||
@ -21,6 +22,7 @@ export * from './asset.entity';
 | 
				
			|||||||
export * from './audit.entity';
 | 
					export * from './audit.entity';
 | 
				
			||||||
export * from './exif.entity';
 | 
					export * from './exif.entity';
 | 
				
			||||||
export * from './library.entity';
 | 
					export * from './library.entity';
 | 
				
			||||||
 | 
					export * from './move.entity';
 | 
				
			||||||
export * from './partner.entity';
 | 
					export * from './partner.entity';
 | 
				
			||||||
export * from './person.entity';
 | 
					export * from './person.entity';
 | 
				
			||||||
export * from './shared-link.entity';
 | 
					export * from './shared-link.entity';
 | 
				
			||||||
@ -37,6 +39,7 @@ export const databaseEntities = [
 | 
				
			|||||||
  AssetFaceEntity,
 | 
					  AssetFaceEntity,
 | 
				
			||||||
  AuditEntity,
 | 
					  AuditEntity,
 | 
				
			||||||
  ExifEntity,
 | 
					  ExifEntity,
 | 
				
			||||||
 | 
					  MoveEntity,
 | 
				
			||||||
  PartnerEntity,
 | 
					  PartnerEntity,
 | 
				
			||||||
  PersonEntity,
 | 
					  PersonEntity,
 | 
				
			||||||
  SharedLinkEntity,
 | 
					  SharedLinkEntity,
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										37
									
								
								server/src/infra/entities/move.entity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								server/src/infra/entities/move.entity.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Entity('move_history')
 | 
				
			||||||
 | 
					// path lock (per entity)
 | 
				
			||||||
 | 
					@Unique('UQ_entityId_pathType', ['entityId', 'pathType'])
 | 
				
			||||||
 | 
					// new path lock (global)
 | 
				
			||||||
 | 
					@Unique('UQ_newPath', ['newPath'])
 | 
				
			||||||
 | 
					export class MoveEntity {
 | 
				
			||||||
 | 
					  @PrimaryGeneratedColumn('uuid')
 | 
				
			||||||
 | 
					  id!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Column({ type: 'varchar' })
 | 
				
			||||||
 | 
					  entityId!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Column({ type: 'varchar' })
 | 
				
			||||||
 | 
					  pathType!: PathType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Column({ type: 'varchar' })
 | 
				
			||||||
 | 
					  oldPath!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Column({ type: 'varchar' })
 | 
				
			||||||
 | 
					  newPath!: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export enum AssetPathType {
 | 
				
			||||||
 | 
					  ORIGINAL = 'original',
 | 
				
			||||||
 | 
					  JPEG_THUMBNAIL = 'jpeg_thumbnail',
 | 
				
			||||||
 | 
					  WEBP_THUMBNAIL = 'webp_thumbnail',
 | 
				
			||||||
 | 
					  ENCODED_VIDEO = 'encoded_video',
 | 
				
			||||||
 | 
					  SIDECAR = 'sidecar',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export enum PersonPathType {
 | 
				
			||||||
 | 
					  FACE = 'face',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type PathType = AssetPathType | PersonPathType;
 | 
				
			||||||
@ -11,6 +11,7 @@ import {
 | 
				
			|||||||
  IMachineLearningRepository,
 | 
					  IMachineLearningRepository,
 | 
				
			||||||
  IMediaRepository,
 | 
					  IMediaRepository,
 | 
				
			||||||
  IMetadataRepository,
 | 
					  IMetadataRepository,
 | 
				
			||||||
 | 
					  IMoveRepository,
 | 
				
			||||||
  IPartnerRepository,
 | 
					  IPartnerRepository,
 | 
				
			||||||
  IPersonRepository,
 | 
					  IPersonRepository,
 | 
				
			||||||
  ISearchRepository,
 | 
					  ISearchRepository,
 | 
				
			||||||
@ -44,6 +45,7 @@ import {
 | 
				
			|||||||
  MachineLearningRepository,
 | 
					  MachineLearningRepository,
 | 
				
			||||||
  MediaRepository,
 | 
					  MediaRepository,
 | 
				
			||||||
  MetadataRepository,
 | 
					  MetadataRepository,
 | 
				
			||||||
 | 
					  MoveRepository,
 | 
				
			||||||
  PartnerRepository,
 | 
					  PartnerRepository,
 | 
				
			||||||
  PersonRepository,
 | 
					  PersonRepository,
 | 
				
			||||||
  SharedLinkRepository,
 | 
					  SharedLinkRepository,
 | 
				
			||||||
@ -67,6 +69,7 @@ const providers: Provider[] = [
 | 
				
			|||||||
  { provide: IKeyRepository, useClass: APIKeyRepository },
 | 
					  { provide: IKeyRepository, useClass: APIKeyRepository },
 | 
				
			||||||
  { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
 | 
					  { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
 | 
				
			||||||
  { provide: IMetadataRepository, useClass: MetadataRepository },
 | 
					  { provide: IMetadataRepository, useClass: MetadataRepository },
 | 
				
			||||||
 | 
					  { provide: IMoveRepository, useClass: MoveRepository },
 | 
				
			||||||
  { provide: IPartnerRepository, useClass: PartnerRepository },
 | 
					  { provide: IPartnerRepository, useClass: PartnerRepository },
 | 
				
			||||||
  { provide: IPersonRepository, useClass: PersonRepository },
 | 
					  { provide: IPersonRepository, useClass: PersonRepository },
 | 
				
			||||||
  { provide: ISearchRepository, useClass: TypesenseRepository },
 | 
					  { provide: ISearchRepository, useClass: TypesenseRepository },
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								server/src/infra/migrations/1696968880063-AddMoveTable.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								server/src/infra/migrations/1696968880063-AddMoveTable.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					import { MigrationInterface, QueryRunner } from "typeorm";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class AddMoveTable1696968880063 implements MigrationInterface {
 | 
				
			||||||
 | 
					    name = 'AddMoveTable1696968880063'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async up(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					        await queryRunner.query(`CREATE TABLE "move_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "entityId" character varying NOT NULL, "pathType" character varying NOT NULL, "oldPath" character varying NOT NULL, "newPath" character varying NOT NULL, CONSTRAINT "UQ_newPath" UNIQUE ("newPath"), CONSTRAINT "UQ_entityId_pathType" UNIQUE ("entityId", "pathType"), CONSTRAINT "PK_af608f132233acf123f2949678d" PRIMARY KEY ("id"))`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async down(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					        await queryRunner.query(`DROP TABLE "move_history"`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -6,6 +6,7 @@ import {
 | 
				
			|||||||
  IStorageRepository,
 | 
					  IStorageRepository,
 | 
				
			||||||
  mimeTypes,
 | 
					  mimeTypes,
 | 
				
			||||||
} from '@app/domain';
 | 
					} from '@app/domain';
 | 
				
			||||||
 | 
					import { Logger } from '@nestjs/common';
 | 
				
			||||||
import archiver from 'archiver';
 | 
					import archiver from 'archiver';
 | 
				
			||||||
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
 | 
					import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
 | 
				
			||||||
import fs, { readdir, writeFile } from 'fs/promises';
 | 
					import fs, { readdir, writeFile } from 'fs/promises';
 | 
				
			||||||
@ -17,6 +18,8 @@ import path from 'path';
 | 
				
			|||||||
const moveFile = promisify<string, string, mv.Options>(mv);
 | 
					const moveFile = promisify<string, string, mv.Options>(mv);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class FilesystemProvider implements IStorageRepository {
 | 
					export class FilesystemProvider implements IStorageRepository {
 | 
				
			||||||
 | 
					  private logger = new Logger(FilesystemProvider.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  createZipStream(): ImmichZipStream {
 | 
					  createZipStream(): ImmichZipStream {
 | 
				
			||||||
    const archive = archiver('zip', { store: true });
 | 
					    const archive = archiver('zip', { store: true });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -52,6 +55,8 @@ export class FilesystemProvider implements IStorageRepository {
 | 
				
			|||||||
  writeFile = writeFile;
 | 
					  writeFile = writeFile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async moveFile(source: string, destination: string): Promise<void> {
 | 
					  async moveFile(source: string, destination: string): Promise<void> {
 | 
				
			||||||
 | 
					    this.logger.verbose(`Moving ${source} to ${destination}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (await this.checkFileExists(destination)) {
 | 
					    if (await this.checkFileExists(destination)) {
 | 
				
			||||||
      throw new Error(`Destination file already exists: ${destination}`);
 | 
					      throw new Error(`Destination file already exists: ${destination}`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ export * from './library.repository';
 | 
				
			|||||||
export * from './machine-learning.repository';
 | 
					export * from './machine-learning.repository';
 | 
				
			||||||
export * from './media.repository';
 | 
					export * from './media.repository';
 | 
				
			||||||
export * from './metadata.repository';
 | 
					export * from './metadata.repository';
 | 
				
			||||||
 | 
					export * from './move.repository';
 | 
				
			||||||
export * from './partner.repository';
 | 
					export * from './partner.repository';
 | 
				
			||||||
export * from './person.repository';
 | 
					export * from './person.repository';
 | 
				
			||||||
export * from './shared-link.repository';
 | 
					export * from './shared-link.repository';
 | 
				
			||||||
 | 
				
			|||||||
@ -70,6 +70,8 @@ export class JobRepository implements IJobRepository {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  private getJobOptions(item: JobItem): JobsOptions | null {
 | 
					  private getJobOptions(item: JobItem): JobsOptions | null {
 | 
				
			||||||
    switch (item.name) {
 | 
					    switch (item.name) {
 | 
				
			||||||
 | 
					      case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE:
 | 
				
			||||||
 | 
					        return { jobId: item.data.id };
 | 
				
			||||||
      case JobName.GENERATE_PERSON_THUMBNAIL:
 | 
					      case JobName.GENERATE_PERSON_THUMBNAIL:
 | 
				
			||||||
        return { priority: 1 };
 | 
					        return { priority: 1 };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								server/src/infra/repositories/move.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								server/src/infra/repositories/move.repository.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					import { IMoveRepository, MoveCreate } from '@app/domain';
 | 
				
			||||||
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
 | 
					import { Repository } from 'typeorm';
 | 
				
			||||||
 | 
					import { MoveEntity, PathType } from '../entities';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Injectable()
 | 
				
			||||||
 | 
					export class MoveRepository implements IMoveRepository {
 | 
				
			||||||
 | 
					  constructor(@InjectRepository(MoveEntity) private repository: Repository<MoveEntity>) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  create(entity: MoveCreate): Promise<MoveEntity> {
 | 
				
			||||||
 | 
					    return this.repository.save(entity);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getByEntity(entityId: string, pathType: PathType): Promise<MoveEntity | null> {
 | 
				
			||||||
 | 
					    return this.repository.findOne({ where: { entityId, pathType } });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  update(entity: Partial<MoveEntity>): Promise<MoveEntity> {
 | 
				
			||||||
 | 
					    return this.repository.save(entity);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  delete(move: MoveEntity): Promise<MoveEntity> {
 | 
				
			||||||
 | 
					    return this.repository.remove(move);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										39
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										39
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -116,6 +116,45 @@ export const assetStub = {
 | 
				
			|||||||
    sidecarPath: null,
 | 
					    sidecarPath: null,
 | 
				
			||||||
    deletedAt: null,
 | 
					    deletedAt: null,
 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
 | 
					  primaryImage: Object.freeze<AssetEntity>({
 | 
				
			||||||
 | 
					    id: 'asset-id',
 | 
				
			||||||
 | 
					    deviceAssetId: 'device-asset-id',
 | 
				
			||||||
 | 
					    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
 | 
				
			||||||
 | 
					    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
 | 
				
			||||||
 | 
					    owner: userStub.admin,
 | 
				
			||||||
 | 
					    ownerId: 'admin-id',
 | 
				
			||||||
 | 
					    deviceId: 'device-id',
 | 
				
			||||||
 | 
					    originalPath: '/original/path.jpg',
 | 
				
			||||||
 | 
					    resizePath: '/uploads/admin-id/thumbs/path.jpg',
 | 
				
			||||||
 | 
					    checksum: Buffer.from('file hash', 'utf8'),
 | 
				
			||||||
 | 
					    type: AssetType.IMAGE,
 | 
				
			||||||
 | 
					    webpPath: '/uploads/admin-id/webp/path.ext',
 | 
				
			||||||
 | 
					    thumbhash: Buffer.from('blablabla', 'base64'),
 | 
				
			||||||
 | 
					    encodedVideoPath: null,
 | 
				
			||||||
 | 
					    createdAt: new Date('2023-02-23T05:06:29.716Z'),
 | 
				
			||||||
 | 
					    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
 | 
				
			||||||
 | 
					    localDateTime: new Date('2023-02-23T05:06:29.716Z'),
 | 
				
			||||||
 | 
					    isFavorite: true,
 | 
				
			||||||
 | 
					    isArchived: false,
 | 
				
			||||||
 | 
					    isReadOnly: false,
 | 
				
			||||||
 | 
					    duration: null,
 | 
				
			||||||
 | 
					    isVisible: true,
 | 
				
			||||||
 | 
					    isExternal: false,
 | 
				
			||||||
 | 
					    livePhotoVideo: null,
 | 
				
			||||||
 | 
					    livePhotoVideoId: null,
 | 
				
			||||||
 | 
					    isOffline: false,
 | 
				
			||||||
 | 
					    libraryId: 'library-id',
 | 
				
			||||||
 | 
					    library: libraryStub.uploadLibrary1,
 | 
				
			||||||
 | 
					    tags: [],
 | 
				
			||||||
 | 
					    sharedLinks: [],
 | 
				
			||||||
 | 
					    originalFileName: 'asset-id.jpg',
 | 
				
			||||||
 | 
					    faces: [],
 | 
				
			||||||
 | 
					    deletedAt: null,
 | 
				
			||||||
 | 
					    sidecarPath: null,
 | 
				
			||||||
 | 
					    exifInfo: {
 | 
				
			||||||
 | 
					      fileSizeInByte: 5_000,
 | 
				
			||||||
 | 
					    } as ExifEntity,
 | 
				
			||||||
 | 
					  }),
 | 
				
			||||||
  image: Object.freeze<AssetEntity>({
 | 
					  image: Object.freeze<AssetEntity>({
 | 
				
			||||||
    id: 'asset-id',
 | 
					    id: 'asset-id',
 | 
				
			||||||
    deviceAssetId: 'device-asset-id',
 | 
					    deviceAssetId: 'device-asset-id',
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ export * from './library.repository.mock';
 | 
				
			|||||||
export * from './machine-learning.repository.mock';
 | 
					export * from './machine-learning.repository.mock';
 | 
				
			||||||
export * from './media.repository.mock';
 | 
					export * from './media.repository.mock';
 | 
				
			||||||
export * from './metadata.repository.mock';
 | 
					export * from './metadata.repository.mock';
 | 
				
			||||||
 | 
					export * from './move.repository.mock';
 | 
				
			||||||
export * from './partner.repository.mock';
 | 
					export * from './partner.repository.mock';
 | 
				
			||||||
export * from './person.repository.mock';
 | 
					export * from './person.repository.mock';
 | 
				
			||||||
export * from './search.repository.mock';
 | 
					export * from './search.repository.mock';
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								server/test/repositories/move.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/test/repositories/move.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					import { IMoveRepository } from '@app/domain';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const newMoveRepositoryMock = (): jest.Mocked<IMoveRepository> => {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    create: jest.fn(),
 | 
				
			||||||
 | 
					    getByEntity: jest.fn(),
 | 
				
			||||||
 | 
					    update: jest.fn(),
 | 
				
			||||||
 | 
					    delete: jest.fn(),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user