mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	refactor(server): jobs and processors (#1787)
* refactor: jobs and processors * refactor: storage migration processor * fix: tests * fix: code warning * chore: ignore coverage from infra * fix: sync move asset logic between job core and asset core * refactor: move error handling inside of catch * refactor(server): job core into dedicated service calls * refactor: smart info * fix: tests * chore: smart info tests * refactor: use asset repository * refactor: thumbnail processor * chore: coverage reqs
This commit is contained in:
		
							
								
								
									
										5
									
								
								server/libs/domain/src/album/album.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								server/libs/domain/src/album/album.repository.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
export const IAlbumRepository = 'IAlbumRepository';
 | 
			
		||||
 | 
			
		||||
export interface IAlbumRepository {
 | 
			
		||||
  deleteAll(userId: string): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
@@ -1 +1,2 @@
 | 
			
		||||
export * from './album.repository';
 | 
			
		||||
export * from './response-dto';
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ export interface IKeyRepository {
 | 
			
		||||
  create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
 | 
			
		||||
  update(userId: string, id: string, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
 | 
			
		||||
  delete(userId: string, id: string): Promise<void>;
 | 
			
		||||
  deleteAll(userId: string): Promise<void>;
 | 
			
		||||
  /**
 | 
			
		||||
   * Includes the hashed `key` for verification
 | 
			
		||||
   * @param id
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								server/libs/domain/src/asset/asset.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/libs/domain/src/asset/asset.repository.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export const IAssetRepository = 'IAssetRepository';
 | 
			
		||||
 | 
			
		||||
export interface IAssetRepository {
 | 
			
		||||
  deleteAll(ownerId: string): Promise<void>;
 | 
			
		||||
  getAll(): Promise<AssetEntity[]>;
 | 
			
		||||
  save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
 | 
			
		||||
  findLivePhotoMatch(livePhotoCID: string, type: AssetType, otherAssetId: string): Promise<AssetEntity | null>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								server/libs/domain/src/asset/asset.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								server/libs/domain/src/asset/asset.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { newJobRepositoryMock } from '../../test';
 | 
			
		||||
import { AssetService } from '../asset';
 | 
			
		||||
import { IJobRepository, JobName } from '../job';
 | 
			
		||||
 | 
			
		||||
describe(AssetService.name, () => {
 | 
			
		||||
  let sut: AssetService;
 | 
			
		||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
			
		||||
 | 
			
		||||
  it('should work', () => {
 | 
			
		||||
    expect(sut).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    jobMock = newJobRepositoryMock();
 | 
			
		||||
    sut = new AssetService(jobMock);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe(`handle asset upload`, () => {
 | 
			
		||||
    it('should process an uploaded video', async () => {
 | 
			
		||||
      const data = { asset: { type: AssetType.VIDEO } as AssetEntity, fileName: 'video.mp4' };
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleAssetUpload(data)).resolves.toBeUndefined();
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledTimes(3);
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([
 | 
			
		||||
        [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }],
 | 
			
		||||
        [{ name: JobName.VIDEO_CONVERSION, data }],
 | 
			
		||||
        [{ name: JobName.EXTRACT_VIDEO_METADATA, data }],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should process an uploaded image', async () => {
 | 
			
		||||
      const data = { asset: { type: AssetType.IMAGE } as AssetEntity, fileName: 'image.jpg' };
 | 
			
		||||
 | 
			
		||||
      await sut.handleAssetUpload(data);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(jobMock.queue.mock.calls).toEqual([
 | 
			
		||||
        [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }],
 | 
			
		||||
        [{ name: JobName.EXIF_EXTRACTION, data }],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										18
									
								
								server/libs/domain/src/asset/asset.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								server/libs/domain/src/asset/asset.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import { AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { Inject } from '@nestjs/common';
 | 
			
		||||
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
 | 
			
		||||
 | 
			
		||||
export class AssetService {
 | 
			
		||||
  constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
 | 
			
		||||
 | 
			
		||||
  async handleAssetUpload(data: IAssetUploadedJob) {
 | 
			
		||||
    await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
 | 
			
		||||
 | 
			
		||||
    if (data.asset.type == AssetType.VIDEO) {
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data });
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.EXTRACT_VIDEO_METADATA, data });
 | 
			
		||||
    } else {
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1 +1,3 @@
 | 
			
		||||
export * from './asset.repository';
 | 
			
		||||
export * from './asset.service';
 | 
			
		||||
export * from './response-dto';
 | 
			
		||||
 
 | 
			
		||||
@@ -42,18 +42,6 @@ const fixtures = {
 | 
			
		||||
 | 
			
		||||
const CLIENT_IP = '127.0.0.1';
 | 
			
		||||
 | 
			
		||||
jest.mock('@nestjs/common', () => ({
 | 
			
		||||
  ...jest.requireActual('@nestjs/common'),
 | 
			
		||||
  Logger: jest.fn().mockReturnValue({
 | 
			
		||||
    verbose: jest.fn(),
 | 
			
		||||
    debug: jest.fn(),
 | 
			
		||||
    log: jest.fn(),
 | 
			
		||||
    info: jest.fn(),
 | 
			
		||||
    warn: jest.fn(),
 | 
			
		||||
    error: jest.fn(),
 | 
			
		||||
  }),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
describe('AuthService', () => {
 | 
			
		||||
  let sut: AuthService;
 | 
			
		||||
  let cryptoMock: jest.Mocked<ICryptoRepository>;
 | 
			
		||||
@@ -208,6 +196,17 @@ describe('AuthService', () => {
 | 
			
		||||
        redirectUri: '/auth/login?autoLaunch=0',
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should delete the access token', async () => {
 | 
			
		||||
      const authUser = { id: '123', accessTokenId: 'token123' } as AuthUserDto;
 | 
			
		||||
 | 
			
		||||
      await expect(sut.logout(authUser, AuthType.PASSWORD)).resolves.toEqual({
 | 
			
		||||
        successful: true,
 | 
			
		||||
        redirectUri: '/auth/login?autoLaunch=0',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('adminSignUp', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
export const ICommunicationRepository = 'ICommunicationRepository';
 | 
			
		||||
 | 
			
		||||
export enum CommunicationEvent {
 | 
			
		||||
  UPLOAD_SUCCESS = 'on_upload_success',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ICommunicationRepository {
 | 
			
		||||
  send(event: CommunicationEvent, userId: string, data: any): void;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								server/libs/domain/src/communication/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/libs/domain/src/communication/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export * from './communication.repository';
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { DeviceInfoEntity, DeviceType } from '@app/infra';
 | 
			
		||||
import { DeviceInfoEntity, DeviceType } from '@app/infra/db/entities';
 | 
			
		||||
import { authStub, newDeviceInfoRepositoryMock } from '../../test';
 | 
			
		||||
import { IDeviceInfoRepository } from './device-info.repository';
 | 
			
		||||
import { DeviceInfoService } from './device-info.service';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,27 @@
 | 
			
		||||
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
 | 
			
		||||
import { APIKeyService } from './api-key';
 | 
			
		||||
import { AssetService } from './asset';
 | 
			
		||||
import { AuthService } from './auth';
 | 
			
		||||
import { DeviceInfoService } from './device-info';
 | 
			
		||||
import { JobService } from './job';
 | 
			
		||||
import { MediaService } from './media';
 | 
			
		||||
import { OAuthService } from './oauth';
 | 
			
		||||
import { ShareService } from './share';
 | 
			
		||||
import { SmartInfoService } from './smart-info';
 | 
			
		||||
import { StorageService } from './storage';
 | 
			
		||||
import { StorageTemplateService } from './storage-template';
 | 
			
		||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
 | 
			
		||||
import { UserService } from './user';
 | 
			
		||||
 | 
			
		||||
const providers: Provider[] = [
 | 
			
		||||
  AssetService,
 | 
			
		||||
  APIKeyService,
 | 
			
		||||
  AuthService,
 | 
			
		||||
  DeviceInfoService,
 | 
			
		||||
  JobService,
 | 
			
		||||
  MediaService,
 | 
			
		||||
  OAuthService,
 | 
			
		||||
  SmartInfoService,
 | 
			
		||||
  StorageService,
 | 
			
		||||
  StorageTemplateService,
 | 
			
		||||
  SystemConfigService,
 | 
			
		||||
  UserService,
 | 
			
		||||
  ShareService,
 | 
			
		||||
 
 | 
			
		||||
@@ -2,13 +2,17 @@ export * from './album';
 | 
			
		||||
export * from './api-key';
 | 
			
		||||
export * from './asset';
 | 
			
		||||
export * from './auth';
 | 
			
		||||
export * from './communication';
 | 
			
		||||
export * from './crypto';
 | 
			
		||||
export * from './device-info';
 | 
			
		||||
export * from './domain.module';
 | 
			
		||||
export * from './job';
 | 
			
		||||
export * from './media';
 | 
			
		||||
export * from './oauth';
 | 
			
		||||
export * from './share';
 | 
			
		||||
export * from './smart-info';
 | 
			
		||||
export * from './storage';
 | 
			
		||||
export * from './storage-template';
 | 
			
		||||
export * from './system-config';
 | 
			
		||||
export * from './tag';
 | 
			
		||||
export * from './user';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
export * from './interfaces';
 | 
			
		||||
export * from './job.constants';
 | 
			
		||||
export * from './job.interface';
 | 
			
		||||
export * from './job.repository';
 | 
			
		||||
export * from './job.service';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IAssetUploadedJob {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Original file name
 | 
			
		||||
   */
 | 
			
		||||
  fileName: string;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IDeleteFileOnDiskJob {
 | 
			
		||||
  assets: AssetEntity[];
 | 
			
		||||
}
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
export * from './asset-uploaded.interface';
 | 
			
		||||
export * from './background-task.interface';
 | 
			
		||||
export * from './machine-learning.interface';
 | 
			
		||||
export * from './metadata-extraction.interface';
 | 
			
		||||
export * from './thumbnail-generation.interface';
 | 
			
		||||
export * from './user-deletion.interface';
 | 
			
		||||
export * from './video-transcode.interface';
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IMachineLearningJob {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,36 +0,0 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IExifExtractionProcessor {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Original file name
 | 
			
		||||
   */
 | 
			
		||||
  fileName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IVideoLengthExtractionProcessor {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Original file name
 | 
			
		||||
   */
 | 
			
		||||
  fileName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IReverseGeocodingProcessor {
 | 
			
		||||
  assetId: string;
 | 
			
		||||
  latitude: number;
 | 
			
		||||
  longitude: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IMetadataExtractionJob =
 | 
			
		||||
  | IExifExtractionProcessor
 | 
			
		||||
  | IVideoLengthExtractionProcessor
 | 
			
		||||
  | IReverseGeocodingProcessor;
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface JpegGeneratorProcessor {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface WebpGeneratorProcessor {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IThumbnailGenerationJob = JpegGeneratorProcessor | WebpGeneratorProcessor;
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
import { UserEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IUserDeletionJob {
 | 
			
		||||
  /**
 | 
			
		||||
   * The user entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  user: UserEntity;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,10 +0,0 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IVideoConversionProcessor {
 | 
			
		||||
  /**
 | 
			
		||||
   * The Asset entity that was saved in the database
 | 
			
		||||
   */
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IVideoTranscodeJob = IVideoConversionProcessor;
 | 
			
		||||
@@ -2,11 +2,9 @@ export enum QueueName {
 | 
			
		||||
  THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
 | 
			
		||||
  METADATA_EXTRACTION = 'metadata-extraction-queue',
 | 
			
		||||
  VIDEO_CONVERSION = 'video-conversion-queue',
 | 
			
		||||
  ASSET_UPLOADED = 'asset-uploaded-queue',
 | 
			
		||||
  MACHINE_LEARNING = 'machine-learning-queue',
 | 
			
		||||
  USER_DELETION = 'user-deletion-queue',
 | 
			
		||||
  CONFIG = 'config-queue',
 | 
			
		||||
  BACKGROUND_TASK = 'background-task',
 | 
			
		||||
  STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum JobName {
 | 
			
		||||
@@ -18,9 +16,10 @@ export enum JobName {
 | 
			
		||||
  EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
 | 
			
		||||
  REVERSE_GEOCODING = 'reverse-geocoding',
 | 
			
		||||
  USER_DELETION = 'user-deletion',
 | 
			
		||||
  TEMPLATE_MIGRATION = 'template-migration',
 | 
			
		||||
  CONFIG_CHANGE = 'config-change',
 | 
			
		||||
  USER_DELETE_CHECK = 'user-delete-check',
 | 
			
		||||
  STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
 | 
			
		||||
  SYSTEM_CONFIG_CHANGE = 'system-config-change',
 | 
			
		||||
  OBJECT_DETECTION = 'detect-object',
 | 
			
		||||
  IMAGE_TAGGING = 'tag-image',
 | 
			
		||||
  DELETE_FILE_ON_DISK = 'delete-file-on-disk',
 | 
			
		||||
  DELETE_FILES = 'delete-files',
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								server/libs/domain/src/job/job.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								server/libs/domain/src/job/job.interface.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import { AssetEntity, UserEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export interface IAssetJob {
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IAssetUploadedJob {
 | 
			
		||||
  asset: AssetEntity;
 | 
			
		||||
  fileName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IDeleteFilesJob {
 | 
			
		||||
  files: Array<string | null | undefined>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IUserDeletionJob {
 | 
			
		||||
  user: UserEntity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IReverseGeocodingJob {
 | 
			
		||||
  assetId: string;
 | 
			
		||||
  latitude: number;
 | 
			
		||||
  longitude: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type IMetadataExtractionJob = IAssetUploadedJob | IReverseGeocodingJob;
 | 
			
		||||
@@ -1,16 +1,5 @@
 | 
			
		||||
import {
 | 
			
		||||
  IAssetUploadedJob,
 | 
			
		||||
  IDeleteFileOnDiskJob,
 | 
			
		||||
  IExifExtractionProcessor,
 | 
			
		||||
  IMachineLearningJob,
 | 
			
		||||
  IVideoConversionProcessor,
 | 
			
		||||
  IReverseGeocodingProcessor,
 | 
			
		||||
  IUserDeletionJob,
 | 
			
		||||
  IVideoLengthExtractionProcessor,
 | 
			
		||||
  JpegGeneratorProcessor,
 | 
			
		||||
  WebpGeneratorProcessor,
 | 
			
		||||
} from './interfaces';
 | 
			
		||||
import { JobName, QueueName } from './job.constants';
 | 
			
		||||
import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface';
 | 
			
		||||
 | 
			
		||||
export interface JobCounts {
 | 
			
		||||
  active: number;
 | 
			
		||||
@@ -20,30 +9,27 @@ export interface JobCounts {
 | 
			
		||||
  waiting: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Job<T> {
 | 
			
		||||
  data: T;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type JobItem =
 | 
			
		||||
  | { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
 | 
			
		||||
  | { name: JobName.VIDEO_CONVERSION; data: IVideoConversionProcessor }
 | 
			
		||||
  | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: JpegGeneratorProcessor }
 | 
			
		||||
  | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: WebpGeneratorProcessor }
 | 
			
		||||
  | { name: JobName.EXIF_EXTRACTION; data: IExifExtractionProcessor }
 | 
			
		||||
  | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingProcessor }
 | 
			
		||||
  | { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
 | 
			
		||||
  | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
 | 
			
		||||
  | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob }
 | 
			
		||||
  | { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob }
 | 
			
		||||
  | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob }
 | 
			
		||||
  | { name: JobName.USER_DELETE_CHECK }
 | 
			
		||||
  | { name: JobName.USER_DELETION; data: IUserDeletionJob }
 | 
			
		||||
  | { name: JobName.TEMPLATE_MIGRATION }
 | 
			
		||||
  | { name: JobName.CONFIG_CHANGE }
 | 
			
		||||
  | { name: JobName.EXTRACT_VIDEO_METADATA; data: IVideoLengthExtractionProcessor }
 | 
			
		||||
  | { name: JobName.OBJECT_DETECTION; data: IMachineLearningJob }
 | 
			
		||||
  | { name: JobName.IMAGE_TAGGING; data: IMachineLearningJob }
 | 
			
		||||
  | { name: JobName.DELETE_FILE_ON_DISK; data: IDeleteFileOnDiskJob };
 | 
			
		||||
  | { name: JobName.STORAGE_TEMPLATE_MIGRATION }
 | 
			
		||||
  | { name: JobName.SYSTEM_CONFIG_CHANGE }
 | 
			
		||||
  | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
 | 
			
		||||
  | { name: JobName.OBJECT_DETECTION; data: IAssetJob }
 | 
			
		||||
  | { name: JobName.IMAGE_TAGGING; data: IAssetJob }
 | 
			
		||||
  | { name: JobName.DELETE_FILES; data: IDeleteFilesJob };
 | 
			
		||||
 | 
			
		||||
export const IJobRepository = 'IJobRepository';
 | 
			
		||||
 | 
			
		||||
export interface IJobRepository {
 | 
			
		||||
  queue(item: JobItem): Promise<void>;
 | 
			
		||||
  empty(name: QueueName): Promise<void>;
 | 
			
		||||
  add(item: JobItem): Promise<void>;
 | 
			
		||||
  isActive(name: QueueName): Promise<boolean>;
 | 
			
		||||
  getJobCounts(name: QueueName): Promise<JobCounts>;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { newJobRepositoryMock } from '../../test';
 | 
			
		||||
import { IAssetUploadedJob } from './interfaces';
 | 
			
		||||
import { JobName } from './job.constants';
 | 
			
		||||
import { IJobRepository, Job } from './job.repository';
 | 
			
		||||
import { JobService } from './job.service';
 | 
			
		||||
 | 
			
		||||
const jobStub = {
 | 
			
		||||
  upload: {
 | 
			
		||||
    video: Object.freeze<Job<IAssetUploadedJob>>({
 | 
			
		||||
      data: { asset: { type: AssetType.VIDEO } as AssetEntity, fileName: 'video.mp4' },
 | 
			
		||||
    }),
 | 
			
		||||
    image: Object.freeze<Job<IAssetUploadedJob>>({
 | 
			
		||||
      data: { asset: { type: AssetType.IMAGE } as AssetEntity, fileName: 'image.jpg' },
 | 
			
		||||
    }),
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe(JobService.name, () => {
 | 
			
		||||
  let sut: JobService;
 | 
			
		||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
			
		||||
 | 
			
		||||
  it('should work', () => {
 | 
			
		||||
    expect(sut).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    jobMock = newJobRepositoryMock();
 | 
			
		||||
    sut = new JobService(jobMock);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleUploadedAsset', () => {
 | 
			
		||||
    it('should process a video', async () => {
 | 
			
		||||
      await expect(sut.handleUploadedAsset(jobStub.upload.video)).resolves.toBeUndefined();
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.add).toHaveBeenCalledTimes(3);
 | 
			
		||||
      expect(jobMock.add.mock.calls).toEqual([
 | 
			
		||||
        [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset: { type: AssetType.VIDEO } } }],
 | 
			
		||||
        [{ name: JobName.VIDEO_CONVERSION, data: { asset: { type: AssetType.VIDEO } } }],
 | 
			
		||||
        [{ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset: { type: AssetType.VIDEO }, fileName: 'video.mp4' } }],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should process an image', async () => {
 | 
			
		||||
      await sut.handleUploadedAsset(jobStub.upload.image);
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.add).toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(jobMock.add.mock.calls).toEqual([
 | 
			
		||||
        [{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset: { type: AssetType.IMAGE } } }],
 | 
			
		||||
        [{ name: JobName.EXIF_EXTRACTION, data: { asset: { type: AssetType.IMAGE }, fileName: 'image.jpg' } }],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
import { Inject, Injectable } from '@nestjs/common';
 | 
			
		||||
import { IAssetUploadedJob } from './interfaces';
 | 
			
		||||
import { JobUploadCore } from './job.upload.core';
 | 
			
		||||
import { IJobRepository, Job } from './job.repository';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class JobService {
 | 
			
		||||
  private uploadCore: JobUploadCore;
 | 
			
		||||
 | 
			
		||||
  constructor(@Inject(IJobRepository) repository: IJobRepository) {
 | 
			
		||||
    this.uploadCore = new JobUploadCore(repository);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleUploadedAsset(job: Job<IAssetUploadedJob>) {
 | 
			
		||||
    await this.uploadCore.handleAsset(job);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,32 +0,0 @@
 | 
			
		||||
import { AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { IAssetUploadedJob } from './interfaces';
 | 
			
		||||
import { JobName } from './job.constants';
 | 
			
		||||
import { IJobRepository, Job } from './job.repository';
 | 
			
		||||
 | 
			
		||||
export class JobUploadCore {
 | 
			
		||||
  constructor(private repository: IJobRepository) {}
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Post processing uploaded asset to perform the following function
 | 
			
		||||
   * 1. Generate JPEG Thumbnail
 | 
			
		||||
   * 2. Generate Webp Thumbnail
 | 
			
		||||
   * 3. EXIF extractor
 | 
			
		||||
   * 4. Reverse Geocoding
 | 
			
		||||
   *
 | 
			
		||||
   * @param job asset-uploaded
 | 
			
		||||
   */
 | 
			
		||||
  async handleAsset(job: Job<IAssetUploadedJob>) {
 | 
			
		||||
    const { asset, fileName } = job.data;
 | 
			
		||||
 | 
			
		||||
    await this.repository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
 | 
			
		||||
 | 
			
		||||
    // Video Conversion
 | 
			
		||||
    if (asset.type == AssetType.VIDEO) {
 | 
			
		||||
      await this.repository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
 | 
			
		||||
      await this.repository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName } });
 | 
			
		||||
    } else {
 | 
			
		||||
      // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
 | 
			
		||||
      await this.repository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName } });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								server/libs/domain/src/media/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/media/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
export * from './media.repository';
 | 
			
		||||
export * from './media.service';
 | 
			
		||||
							
								
								
									
										12
									
								
								server/libs/domain/src/media/media.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/libs/domain/src/media/media.repository.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
export const IMediaRepository = 'IMediaRepository';
 | 
			
		||||
 | 
			
		||||
export interface ResizeOptions {
 | 
			
		||||
  size: number;
 | 
			
		||||
  format: 'webp' | 'jpeg';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMediaRepository {
 | 
			
		||||
  resize(input: string, output: string, options: ResizeOptions): Promise<void>;
 | 
			
		||||
  extractVideoThumbnail(input: string, output: string): Promise<void>;
 | 
			
		||||
  extractThumbnailFromExif(input: string, output: string): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										99
									
								
								server/libs/domain/src/media/media.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								server/libs/domain/src/media/media.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
			
		||||
import { APP_UPLOAD_LOCATION } from '@app/common';
 | 
			
		||||
import { AssetType } from '@app/infra/db/entities';
 | 
			
		||||
import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { join } from 'path';
 | 
			
		||||
import sanitize from 'sanitize-filename';
 | 
			
		||||
import { IAssetRepository, mapAsset } from '../asset';
 | 
			
		||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
 | 
			
		||||
import { IAssetJob, IJobRepository, JobName } from '../job';
 | 
			
		||||
import { IStorageRepository } from '../storage';
 | 
			
		||||
import { IMediaRepository } from './media.repository';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class MediaService {
 | 
			
		||||
  private logger = new Logger(MediaService.name);
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
			
		||||
    @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
 | 
			
		||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
			
		||||
    @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
 | 
			
		||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> {
 | 
			
		||||
    const { asset } = data;
 | 
			
		||||
 | 
			
		||||
    const basePath = APP_UPLOAD_LOCATION;
 | 
			
		||||
    const sanitizedDeviceId = sanitize(String(asset.deviceId));
 | 
			
		||||
    const resizePath = join(basePath, asset.ownerId, 'thumb', sanitizedDeviceId);
 | 
			
		||||
    const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
 | 
			
		||||
 | 
			
		||||
    this.storageRepository.mkdirSync(resizePath);
 | 
			
		||||
 | 
			
		||||
    if (asset.type == AssetType.IMAGE) {
 | 
			
		||||
      try {
 | 
			
		||||
        await this.mediaRepository
 | 
			
		||||
          .resize(asset.originalPath, jpegThumbnailPath, { size: 1440, format: 'jpeg' })
 | 
			
		||||
          .catch(() => {
 | 
			
		||||
            this.logger.warn(
 | 
			
		||||
              'Failed to generate jpeg thumbnail for asset: ' +
 | 
			
		||||
                asset.id +
 | 
			
		||||
                ' using sharp, failing over to exiftool-vendored',
 | 
			
		||||
            );
 | 
			
		||||
            return this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath);
 | 
			
		||||
          });
 | 
			
		||||
        await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        this.logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id, error.stack);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Update resize path to send to generate webp queue
 | 
			
		||||
      asset.resizePath = jpegThumbnailPath;
 | 
			
		||||
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
 | 
			
		||||
 | 
			
		||||
      this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (asset.type == AssetType.VIDEO) {
 | 
			
		||||
      try {
 | 
			
		||||
        this.logger.log('Start Generating Video Thumbnail');
 | 
			
		||||
        await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath);
 | 
			
		||||
        this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
 | 
			
		||||
 | 
			
		||||
        await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
 | 
			
		||||
 | 
			
		||||
        // Update resize path to send to generate webp queue
 | 
			
		||||
        asset.resizePath = jpegThumbnailPath;
 | 
			
		||||
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
 | 
			
		||||
 | 
			
		||||
        this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        this.logger.error(`Cannot Generate Video Thumbnail: ${asset.id}`, error?.stack);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleGenerateWepbThumbnail(data: IAssetJob): Promise<void> {
 | 
			
		||||
    const { asset } = data;
 | 
			
		||||
 | 
			
		||||
    if (!asset.resizePath) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const webpPath = asset.resizePath.replace('jpeg', 'webp');
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await this.mediaRepository.resize(asset.resizePath, webpPath, { size: 250, format: 'webp' });
 | 
			
		||||
      await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error('Failed to generate webp thumbnail for asset: ' + asset.id, error.stack);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -21,18 +21,6 @@ import { newUserTokenRepositoryMock } from '../../test/user-token.repository.moc
 | 
			
		||||
const email = 'user@immich.com';
 | 
			
		||||
const sub = 'my-auth-user-sub';
 | 
			
		||||
 | 
			
		||||
jest.mock('@nestjs/common', () => ({
 | 
			
		||||
  ...jest.requireActual('@nestjs/common'),
 | 
			
		||||
  Logger: jest.fn().mockReturnValue({
 | 
			
		||||
    verbose: jest.fn(),
 | 
			
		||||
    debug: jest.fn(),
 | 
			
		||||
    log: jest.fn(),
 | 
			
		||||
    info: jest.fn(),
 | 
			
		||||
    warn: jest.fn(),
 | 
			
		||||
    error: jest.fn(),
 | 
			
		||||
  }),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
describe('OAuthService', () => {
 | 
			
		||||
  let sut: OAuthService;
 | 
			
		||||
  let userMock: jest.Mocked<IUserRepository>;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								server/libs/domain/src/smart-info/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								server/libs/domain/src/smart-info/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
export * from './machine-learning.interface';
 | 
			
		||||
export * from './smart-info.repository';
 | 
			
		||||
export * from './smart-info.service';
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
export const IMachineLearningRepository = 'IMachineLearningRepository';
 | 
			
		||||
 | 
			
		||||
export interface MachineLearningInput {
 | 
			
		||||
  thumbnailPath: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IMachineLearningRepository {
 | 
			
		||||
  tagImage(input: MachineLearningInput): Promise<string[]>;
 | 
			
		||||
  detectObjects(input: MachineLearningInput): Promise<string[]>;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,7 @@
 | 
			
		||||
import { SmartInfoEntity } from '@app/infra/db/entities';
 | 
			
		||||
 | 
			
		||||
export const ISmartInfoRepository = 'ISmartInfoRepository';
 | 
			
		||||
 | 
			
		||||
export interface ISmartInfoRepository {
 | 
			
		||||
  upsert(info: Partial<SmartInfoEntity>): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										102
									
								
								server/libs/domain/src/smart-info/smart-info.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								server/libs/domain/src/smart-info/smart-info.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,102 @@
 | 
			
		||||
import { AssetEntity } from '@app/infra/db/entities';
 | 
			
		||||
import { newMachineLearningRepositoryMock, newSmartInfoRepositoryMock } from '../../test';
 | 
			
		||||
import { IMachineLearningRepository } from './machine-learning.interface';
 | 
			
		||||
import { ISmartInfoRepository } from './smart-info.repository';
 | 
			
		||||
import { SmartInfoService } from './smart-info.service';
 | 
			
		||||
 | 
			
		||||
const asset = {
 | 
			
		||||
  id: 'asset-1',
 | 
			
		||||
  resizePath: 'path/to/resize.ext',
 | 
			
		||||
} as AssetEntity;
 | 
			
		||||
 | 
			
		||||
describe(SmartInfoService.name, () => {
 | 
			
		||||
  let sut: SmartInfoService;
 | 
			
		||||
  let smartMock: jest.Mocked<ISmartInfoRepository>;
 | 
			
		||||
  let machineMock: jest.Mocked<IMachineLearningRepository>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    smartMock = newSmartInfoRepositoryMock();
 | 
			
		||||
    machineMock = newMachineLearningRepositoryMock();
 | 
			
		||||
    sut = new SmartInfoService(smartMock, machineMock);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should work', () => {
 | 
			
		||||
    expect(sut).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleTagImage', () => {
 | 
			
		||||
    it('should skip assets without a resize path', async () => {
 | 
			
		||||
      await sut.handleTagImage({ asset: { resizePath: '' } as AssetEntity });
 | 
			
		||||
 | 
			
		||||
      expect(smartMock.upsert).not.toHaveBeenCalled();
 | 
			
		||||
      expect(machineMock.tagImage).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should save the returned tags', async () => {
 | 
			
		||||
      machineMock.tagImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTagImage({ asset });
 | 
			
		||||
 | 
			
		||||
      expect(machineMock.tagImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
 | 
			
		||||
      expect(smartMock.upsert).toHaveBeenCalledWith({
 | 
			
		||||
        assetId: 'asset-1',
 | 
			
		||||
        tags: ['tag1', 'tag2', 'tag3'],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle an error with the machine learning pipeline', async () => {
 | 
			
		||||
      machineMock.tagImage.mockRejectedValue(new Error('Unable to read thumbnail'));
 | 
			
		||||
 | 
			
		||||
      await sut.handleTagImage({ asset });
 | 
			
		||||
 | 
			
		||||
      expect(smartMock.upsert).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should no update the smart info if no tags were returned', async () => {
 | 
			
		||||
      machineMock.tagImage.mockResolvedValue([]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTagImage({ asset });
 | 
			
		||||
 | 
			
		||||
      expect(machineMock.tagImage).toHaveBeenCalled();
 | 
			
		||||
      expect(smartMock.upsert).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleDetectObjects', () => {
 | 
			
		||||
    it('should skip assets without a resize path', async () => {
 | 
			
		||||
      await sut.handleDetectObjects({ asset: { resizePath: '' } as AssetEntity });
 | 
			
		||||
 | 
			
		||||
      expect(smartMock.upsert).not.toHaveBeenCalled();
 | 
			
		||||
      expect(machineMock.detectObjects).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should save the returned objects', async () => {
 | 
			
		||||
      machineMock.detectObjects.mockResolvedValue(['obj1', 'obj2', 'obj3']);
 | 
			
		||||
 | 
			
		||||
      await sut.handleDetectObjects({ asset });
 | 
			
		||||
 | 
			
		||||
      expect(machineMock.detectObjects).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
 | 
			
		||||
      expect(smartMock.upsert).toHaveBeenCalledWith({
 | 
			
		||||
        assetId: 'asset-1',
 | 
			
		||||
        objects: ['obj1', 'obj2', 'obj3'],
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle an error with the machine learning pipeline', async () => {
 | 
			
		||||
      machineMock.detectObjects.mockRejectedValue(new Error('Unable to read thumbnail'));
 | 
			
		||||
 | 
			
		||||
      await sut.handleDetectObjects({ asset });
 | 
			
		||||
 | 
			
		||||
      expect(smartMock.upsert).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should no update the smart info if no objects were returned', async () => {
 | 
			
		||||
      machineMock.detectObjects.mockResolvedValue([]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleDetectObjects({ asset });
 | 
			
		||||
 | 
			
		||||
      expect(machineMock.detectObjects).toHaveBeenCalled();
 | 
			
		||||
      expect(smartMock.upsert).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										49
									
								
								server/libs/domain/src/smart-info/smart-info.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								server/libs/domain/src/smart-info/smart-info.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
import { MACHINE_LEARNING_ENABLED } from '@app/common';
 | 
			
		||||
import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { IAssetJob } from '../job';
 | 
			
		||||
import { IMachineLearningRepository } from './machine-learning.interface';
 | 
			
		||||
import { ISmartInfoRepository } from './smart-info.repository';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class SmartInfoService {
 | 
			
		||||
  private logger = new Logger(SmartInfoService.name);
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
 | 
			
		||||
    @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async handleTagImage(data: IAssetJob) {
 | 
			
		||||
    const { asset } = data;
 | 
			
		||||
 | 
			
		||||
    if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath });
 | 
			
		||||
      if (tags.length > 0) {
 | 
			
		||||
        await this.repository.upsert({ assetId: asset.id, tags });
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleDetectObjects(data: IAssetJob) {
 | 
			
		||||
    const { asset } = data;
 | 
			
		||||
 | 
			
		||||
    if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
 | 
			
		||||
      if (objects.length > 0) {
 | 
			
		||||
        await this.repository.upsert({ assetId: asset.id, objects });
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error(`Unable run object detection pipeline: ${asset.id}`, error?.stack);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								server/libs/domain/src/storage-template/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/storage-template/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
export * from './storage-template.core';
 | 
			
		||||
export * from './storage-template.service';
 | 
			
		||||
							
								
								
									
										162
									
								
								server/libs/domain/src/storage-template/storage-template.core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								server/libs/domain/src/storage-template/storage-template.core.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,162 @@
 | 
			
		||||
import { APP_UPLOAD_LOCATION } from '@app/common';
 | 
			
		||||
import {
 | 
			
		||||
  IStorageRepository,
 | 
			
		||||
  ISystemConfigRepository,
 | 
			
		||||
  supportedDayTokens,
 | 
			
		||||
  supportedHourTokens,
 | 
			
		||||
  supportedMinuteTokens,
 | 
			
		||||
  supportedMonthTokens,
 | 
			
		||||
  supportedSecondTokens,
 | 
			
		||||
  supportedYearTokens,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { AssetEntity, AssetType, SystemConfig } from '@app/infra/db/entities';
 | 
			
		||||
import { Logger } from '@nestjs/common';
 | 
			
		||||
import handlebar from 'handlebars';
 | 
			
		||||
import * as luxon from 'luxon';
 | 
			
		||||
import path from 'node:path';
 | 
			
		||||
import sanitize from 'sanitize-filename';
 | 
			
		||||
import { SystemConfigCore } from '../system-config/system-config.core';
 | 
			
		||||
 | 
			
		||||
export class StorageTemplateCore {
 | 
			
		||||
  private logger = new Logger(StorageTemplateCore.name);
 | 
			
		||||
  private configCore: SystemConfigCore;
 | 
			
		||||
  private storageTemplate: HandlebarsTemplateDelegate<any>;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    configRepository: ISystemConfigRepository,
 | 
			
		||||
    config: SystemConfig,
 | 
			
		||||
    private storageRepository: IStorageRepository,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.storageTemplate = this.compile(config.storageTemplate.template);
 | 
			
		||||
    this.configCore = new SystemConfigCore(configRepository);
 | 
			
		||||
    this.configCore.addValidator((config) => this.validateConfig(config));
 | 
			
		||||
    this.configCore.config$.subscribe((config) => this.onConfig(config));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getTemplatePath(asset: AssetEntity, filename: string): Promise<string> {
 | 
			
		||||
    try {
 | 
			
		||||
      const source = asset.originalPath;
 | 
			
		||||
      const ext = path.extname(source).split('.').pop() as string;
 | 
			
		||||
      const sanitized = sanitize(path.basename(filename, `.${ext}`));
 | 
			
		||||
      const rootPath = path.join(APP_UPLOAD_LOCATION, asset.ownerId);
 | 
			
		||||
      const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
 | 
			
		||||
      const fullPath = path.normalize(path.join(rootPath, storagePath));
 | 
			
		||||
      let destination = `${fullPath}.${ext}`;
 | 
			
		||||
 | 
			
		||||
      if (!fullPath.startsWith(rootPath)) {
 | 
			
		||||
        this.logger.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`);
 | 
			
		||||
        return source;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (source === destination) {
 | 
			
		||||
        return source;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      /**
 | 
			
		||||
       * In case of migrating duplicate filename to a new path, we need to check if it is already migrated
 | 
			
		||||
       * Due to the mechanism of appending +1, +2, +3, etc to the filename
 | 
			
		||||
       *
 | 
			
		||||
       * Example:
 | 
			
		||||
       * Source = upload/abc/def/FullSizeRender+7.heic
 | 
			
		||||
       * Expected Destination = upload/abc/def/FullSizeRender.heic
 | 
			
		||||
       *
 | 
			
		||||
       * The file is already at the correct location, but since there are other FullSizeRender.heic files in the
 | 
			
		||||
       * destination, it was renamed to FullSizeRender+7.heic.
 | 
			
		||||
       *
 | 
			
		||||
       * The lines below will be used to check if the differences between the source and destination is only the
 | 
			
		||||
       * +7 suffix, and if so, it will be considered as already migrated.
 | 
			
		||||
       */
 | 
			
		||||
      if (source.startsWith(fullPath) && source.endsWith(`.${ext}`)) {
 | 
			
		||||
        const diff = source.replace(fullPath, '').replace(`.${ext}`, '');
 | 
			
		||||
        const hasDuplicationAnnotation = /^\+\d+$/.test(diff);
 | 
			
		||||
        if (hasDuplicationAnnotation) {
 | 
			
		||||
          return source;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let duplicateCount = 0;
 | 
			
		||||
 | 
			
		||||
      while (true) {
 | 
			
		||||
        const exists = await this.storageRepository.checkFileExists(destination);
 | 
			
		||||
        if (!exists) {
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        duplicateCount++;
 | 
			
		||||
        destination = `${fullPath}+${duplicateCount}.${ext}`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return destination;
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error(`Unable to get template path for ${filename}`, error);
 | 
			
		||||
      return asset.originalPath;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private validateConfig(config: SystemConfig) {
 | 
			
		||||
    this.validateStorageTemplate(config.storageTemplate.template);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private validateStorageTemplate(templateString: string) {
 | 
			
		||||
    try {
 | 
			
		||||
      const template = this.compile(templateString);
 | 
			
		||||
      // test render an asset
 | 
			
		||||
      this.render(
 | 
			
		||||
        template,
 | 
			
		||||
        {
 | 
			
		||||
          fileCreatedAt: new Date().toISOString(),
 | 
			
		||||
          originalPath: '/upload/test/IMG_123.jpg',
 | 
			
		||||
          type: AssetType.IMAGE,
 | 
			
		||||
        } as AssetEntity,
 | 
			
		||||
        'IMG_123',
 | 
			
		||||
        'jpg',
 | 
			
		||||
      );
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`);
 | 
			
		||||
      throw new Error(`Invalid storage template: ${e}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onConfig(config: SystemConfig) {
 | 
			
		||||
    this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
 | 
			
		||||
    this.storageTemplate = this.compile(config.storageTemplate.template);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private compile(template: string) {
 | 
			
		||||
    return handlebar.compile(template, {
 | 
			
		||||
      knownHelpers: undefined,
 | 
			
		||||
      strict: true,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) {
 | 
			
		||||
    const substitutions: Record<string, string> = {
 | 
			
		||||
      filename,
 | 
			
		||||
      ext,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const fileType = asset.type == AssetType.IMAGE ? 'IMG' : 'VID';
 | 
			
		||||
    const fileTypeFull = asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO';
 | 
			
		||||
 | 
			
		||||
    const dt = luxon.DateTime.fromISO(new Date(asset.fileCreatedAt).toISOString());
 | 
			
		||||
 | 
			
		||||
    const dateTokens = [
 | 
			
		||||
      ...supportedYearTokens,
 | 
			
		||||
      ...supportedMonthTokens,
 | 
			
		||||
      ...supportedDayTokens,
 | 
			
		||||
      ...supportedHourTokens,
 | 
			
		||||
      ...supportedMinuteTokens,
 | 
			
		||||
      ...supportedSecondTokens,
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    for (const token of dateTokens) {
 | 
			
		||||
      substitutions[token] = dt.toFormat(token);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Support file type token
 | 
			
		||||
    substitutions.filetype = fileType;
 | 
			
		||||
    substitutions.filetypefull = fileTypeFull;
 | 
			
		||||
 | 
			
		||||
    return template(substitutions);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,149 @@
 | 
			
		||||
import { when } from 'jest-when';
 | 
			
		||||
import {
 | 
			
		||||
  assetEntityStub,
 | 
			
		||||
  newAssetRepositoryMock,
 | 
			
		||||
  newStorageRepositoryMock,
 | 
			
		||||
  newSystemConfigRepositoryMock,
 | 
			
		||||
  systemConfigStub,
 | 
			
		||||
} from '../../test';
 | 
			
		||||
import { IAssetRepository } from '../asset';
 | 
			
		||||
import { StorageTemplateService } from '../storage-template';
 | 
			
		||||
import { IStorageRepository } from '../storage/storage.repository';
 | 
			
		||||
import { ISystemConfigRepository } from '../system-config';
 | 
			
		||||
 | 
			
		||||
describe(StorageTemplateService.name, () => {
 | 
			
		||||
  let sut: StorageTemplateService;
 | 
			
		||||
  let assetMock: jest.Mocked<IAssetRepository>;
 | 
			
		||||
  let configMock: jest.Mocked<ISystemConfigRepository>;
 | 
			
		||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
			
		||||
 | 
			
		||||
  it('should work', () => {
 | 
			
		||||
    expect(sut).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    assetMock = newAssetRepositoryMock();
 | 
			
		||||
    configMock = newSystemConfigRepositoryMock();
 | 
			
		||||
    storageMock = newStorageRepositoryMock();
 | 
			
		||||
    sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handle template migration', () => {
 | 
			
		||||
    it('should handle no assets', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle an asset with a duplicate destination', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetEntityStub.image);
 | 
			
		||||
 | 
			
		||||
      when(storageMock.checkFileExists)
 | 
			
		||||
        .calledWith('upload/user-id/2023/2023-02-23/asset-id.ext')
 | 
			
		||||
        .mockResolvedValue(true);
 | 
			
		||||
 | 
			
		||||
      when(storageMock.checkFileExists)
 | 
			
		||||
        .calledWith('upload/user-id/2023/2023-02-23/asset-id+1.ext')
 | 
			
		||||
        .mockResolvedValue(false);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetEntityStub.image.id,
 | 
			
		||||
        originalPath: 'upload/user-id/2023/2023-02-23/asset-id+1.ext',
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should skip when an asset already matches the template', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([
 | 
			
		||||
        {
 | 
			
		||||
          ...assetEntityStub.image,
 | 
			
		||||
          originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext',
 | 
			
		||||
        },
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.moveFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should skip when an asset is probably a duplicate', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([
 | 
			
		||||
        {
 | 
			
		||||
          ...assetEntityStub.image,
 | 
			
		||||
          originalPath: 'upload/user-id/2023/2023-02-23/asset-id+1.ext',
 | 
			
		||||
        },
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.moveFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should move an asset', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetEntityStub.image);
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.moveFile).toHaveBeenCalledWith(
 | 
			
		||||
        '/original/path.ext',
 | 
			
		||||
        'upload/user-id/2023/2023-02-23/asset-id.ext',
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetEntityStub.image.id,
 | 
			
		||||
        originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext',
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not update the database if the move fails', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
 | 
			
		||||
      storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.moveFile).toHaveBeenCalledWith(
 | 
			
		||||
        '/original/path.ext',
 | 
			
		||||
        'upload/user-id/2023/2023-02-23/asset-id.ext',
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should move the asset back if the database fails', async () => {
 | 
			
		||||
      assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
 | 
			
		||||
      assetMock.save.mockRejectedValue('Connection Error!');
 | 
			
		||||
 | 
			
		||||
      await sut.handleTemplateMigration();
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetEntityStub.image.id,
 | 
			
		||||
        originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext',
 | 
			
		||||
      });
 | 
			
		||||
      expect(storageMock.moveFile.mock.calls).toEqual([
 | 
			
		||||
        ['/original/path.ext', 'upload/user-id/2023/2023-02-23/asset-id.ext'],
 | 
			
		||||
        ['upload/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should handle an error', async () => {
 | 
			
		||||
    assetMock.getAll.mockResolvedValue([]);
 | 
			
		||||
    storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem'));
 | 
			
		||||
 | 
			
		||||
    await sut.handleTemplateMigration();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -0,0 +1,73 @@
 | 
			
		||||
import { APP_UPLOAD_LOCATION } from '@app/common';
 | 
			
		||||
import { AssetEntity, SystemConfig } from '@app/infra/db/entities';
 | 
			
		||||
import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { IAssetRepository } from '../asset/asset.repository';
 | 
			
		||||
import { IStorageRepository } from '../storage/storage.repository';
 | 
			
		||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 | 
			
		||||
import { StorageTemplateCore } from './storage-template.core';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class StorageTemplateService {
 | 
			
		||||
  private logger = new Logger(StorageTemplateService.name);
 | 
			
		||||
  private core: StorageTemplateCore;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
			
		||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
			
		||||
    @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
 | 
			
		||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.core = new StorageTemplateCore(configRepository, config, storageRepository);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleTemplateMigration() {
 | 
			
		||||
    try {
 | 
			
		||||
      console.time('migrating-time');
 | 
			
		||||
      const assets = await this.assetRepository.getAll();
 | 
			
		||||
 | 
			
		||||
      const livePhotoMap: Record<string, AssetEntity> = {};
 | 
			
		||||
 | 
			
		||||
      for (const asset of assets) {
 | 
			
		||||
        if (asset.livePhotoVideoId) {
 | 
			
		||||
          livePhotoMap[asset.livePhotoVideoId] = asset;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (const asset of assets) {
 | 
			
		||||
        const livePhotoParentAsset = livePhotoMap[asset.id];
 | 
			
		||||
        // TODO: remove livePhoto specific stuff once upload is fixed
 | 
			
		||||
        const filename = asset.exifInfo?.imageName || livePhotoParentAsset?.exifInfo?.imageName || asset.id;
 | 
			
		||||
        await this.moveAsset(asset, filename);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.logger.debug('Cleaning up empty directories...');
 | 
			
		||||
      await this.storageRepository.removeEmptyDirs(APP_UPLOAD_LOCATION);
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error('Error running template migration', error);
 | 
			
		||||
    } finally {
 | 
			
		||||
      console.timeEnd('migrating-time');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // TODO: use asset core (once in domain)
 | 
			
		||||
  async moveAsset(asset: AssetEntity, originalName: string) {
 | 
			
		||||
    const destination = await this.core.getTemplatePath(asset, originalName);
 | 
			
		||||
    if (asset.originalPath !== destination) {
 | 
			
		||||
      const source = asset.originalPath;
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await this.storageRepository.moveFile(asset.originalPath, destination);
 | 
			
		||||
        try {
 | 
			
		||||
          await this.assetRepository.save({ id: asset.id, originalPath: destination });
 | 
			
		||||
          asset.originalPath = destination;
 | 
			
		||||
        } catch (error: any) {
 | 
			
		||||
          this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack);
 | 
			
		||||
          await this.storageRepository.moveFile(destination, source);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return asset;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1 +1,2 @@
 | 
			
		||||
export * from './storage.repository';
 | 
			
		||||
export * from './storage.service';
 | 
			
		||||
 
 | 
			
		||||
@@ -10,4 +10,10 @@ export const IStorageRepository = 'IStorageRepository';
 | 
			
		||||
 | 
			
		||||
export interface IStorageRepository {
 | 
			
		||||
  createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream>;
 | 
			
		||||
  unlink(filepath: string): Promise<void>;
 | 
			
		||||
  unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
 | 
			
		||||
  removeEmptyDirs(folder: string): Promise<void>;
 | 
			
		||||
  moveFile(source: string, target: string): Promise<void>;
 | 
			
		||||
  checkFileExists(filepath: string): Promise<boolean>;
 | 
			
		||||
  mkdirSync(filepath: string): void;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										39
									
								
								server/libs/domain/src/storage/storage.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								server/libs/domain/src/storage/storage.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
import { newStorageRepositoryMock } from '../../test';
 | 
			
		||||
import { IStorageRepository } from '../storage';
 | 
			
		||||
import { StorageService } from './storage.service';
 | 
			
		||||
 | 
			
		||||
describe(StorageService.name, () => {
 | 
			
		||||
  let sut: StorageService;
 | 
			
		||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    storageMock = newStorageRepositoryMock();
 | 
			
		||||
    sut = new StorageService(storageMock);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should work', () => {
 | 
			
		||||
    expect(sut).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleDeleteFiles', () => {
 | 
			
		||||
    it('should handle null values', async () => {
 | 
			
		||||
      await sut.handleDeleteFiles({ files: [undefined, null] });
 | 
			
		||||
 | 
			
		||||
      expect(storageMock.unlink).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle an error removing a file', async () => {
 | 
			
		||||
      storageMock.unlink.mockRejectedValue(new Error('something-went-wrong'));
 | 
			
		||||
 | 
			
		||||
      await sut.handleDeleteFiles({ files: ['path/to/something'] });
 | 
			
		||||
 | 
			
		||||
      expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should remove the file', async () => {
 | 
			
		||||
      await sut.handleDeleteFiles({ files: ['path/to/something'] });
 | 
			
		||||
 | 
			
		||||
      expect(storageMock.unlink).toHaveBeenCalledWith('path/to/something');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										26
									
								
								server/libs/domain/src/storage/storage.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								server/libs/domain/src/storage/storage.service.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import { Inject, Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { IDeleteFilesJob } from '../job';
 | 
			
		||||
import { IStorageRepository } from './storage.repository';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class StorageService {
 | 
			
		||||
  private logger = new Logger(StorageService.name);
 | 
			
		||||
 | 
			
		||||
  constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {}
 | 
			
		||||
 | 
			
		||||
  async handleDeleteFiles(job: IDeleteFilesJob) {
 | 
			
		||||
    const { files } = job;
 | 
			
		||||
 | 
			
		||||
    for (const file of files) {
 | 
			
		||||
      if (!file) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await this.storageRepository.unlink(file);
 | 
			
		||||
      } catch (error: any) {
 | 
			
		||||
        this.logger.warn('Unable to remove file from disk', error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -127,7 +127,7 @@ describe(SystemConfigService.name, () => {
 | 
			
		||||
      await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
 | 
			
		||||
 | 
			
		||||
      expect(configMock.saveAll).toHaveBeenCalledWith(updates);
 | 
			
		||||
      expect(jobMock.add).toHaveBeenCalledWith({ name: JobName.CONFIG_CHANGE });
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SYSTEM_CONFIG_CHANGE });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should throw an error if the config is not valid', async () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ export class SystemConfigService {
 | 
			
		||||
  private core: SystemConfigCore;
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
 | 
			
		||||
    @Inject(IJobRepository) private queue: IJobRepository,
 | 
			
		||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.core = new SystemConfigCore(repository);
 | 
			
		||||
  }
 | 
			
		||||
@@ -40,7 +40,7 @@ export class SystemConfigService {
 | 
			
		||||
 | 
			
		||||
  async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
 | 
			
		||||
    const config = await this.core.updateConfig(dto);
 | 
			
		||||
    await this.queue.add({ name: JobName.CONFIG_CHANGE });
 | 
			
		||||
    await this.jobRepository.queue({ name: JobName.SYSTEM_CONFIG_CHANGE });
 | 
			
		||||
    return mapConfig(config);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,5 +5,6 @@ export const IUserTokenRepository = 'IUserTokenRepository';
 | 
			
		||||
export interface IUserTokenRepository {
 | 
			
		||||
  create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
 | 
			
		||||
  delete(userToken: string): Promise<void>;
 | 
			
		||||
  deleteAll(userId: string): Promise<void>;
 | 
			
		||||
  get(userToken: string): Promise<UserTokenEntity | null>;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,9 +11,10 @@ export interface IUserRepository {
 | 
			
		||||
  getAdmin(): Promise<UserEntity | null>;
 | 
			
		||||
  getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
 | 
			
		||||
  getByOAuthId(oauthId: string): Promise<UserEntity | null>;
 | 
			
		||||
  getDeletedUsers(): Promise<UserEntity[]>;
 | 
			
		||||
  getList(filter?: UserListFilter): Promise<UserEntity[]>;
 | 
			
		||||
  create(user: Partial<UserEntity>): Promise<UserEntity>;
 | 
			
		||||
  update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
 | 
			
		||||
  delete(user: UserEntity): Promise<UserEntity>;
 | 
			
		||||
  delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
 | 
			
		||||
  restore(user: UserEntity): Promise<UserEntity>;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,34 @@
 | 
			
		||||
import { IUserRepository } from './user.repository';
 | 
			
		||||
import { UserEntity } from '@app/infra/db/entities';
 | 
			
		||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
 | 
			
		||||
import { when } from 'jest-when';
 | 
			
		||||
import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test';
 | 
			
		||||
import {
 | 
			
		||||
  newAlbumRepositoryMock,
 | 
			
		||||
  newAssetRepositoryMock,
 | 
			
		||||
  newCryptoRepositoryMock,
 | 
			
		||||
  newJobRepositoryMock,
 | 
			
		||||
  newKeyRepositoryMock,
 | 
			
		||||
  newStorageRepositoryMock,
 | 
			
		||||
  newUserRepositoryMock,
 | 
			
		||||
  newUserTokenRepositoryMock,
 | 
			
		||||
} from '../../test';
 | 
			
		||||
import { IAlbumRepository } from '../album';
 | 
			
		||||
import { IKeyRepository } from '../api-key';
 | 
			
		||||
import { IAssetRepository } from '../asset';
 | 
			
		||||
import { AuthUserDto } from '../auth';
 | 
			
		||||
import { ICryptoRepository } from '../crypto';
 | 
			
		||||
import { IJobRepository, JobName } from '../job';
 | 
			
		||||
import { IStorageRepository } from '../storage';
 | 
			
		||||
import { IUserTokenRepository } from '../user-token';
 | 
			
		||||
import { UpdateUserDto } from './dto/update-user.dto';
 | 
			
		||||
import { IUserRepository } from './user.repository';
 | 
			
		||||
import { UserService } from './user.service';
 | 
			
		||||
 | 
			
		||||
const makeDeletedAt = (daysAgo: number) => {
 | 
			
		||||
  const deletedAt = new Date();
 | 
			
		||||
  deletedAt.setDate(deletedAt.getDate() - daysAgo);
 | 
			
		||||
  return deletedAt;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const adminUserAuth: AuthUserDto = Object.freeze({
 | 
			
		||||
  id: 'admin_id',
 | 
			
		||||
  email: 'admin@test.com',
 | 
			
		||||
@@ -83,10 +104,35 @@ describe(UserService.name, () => {
 | 
			
		||||
  let userRepositoryMock: jest.Mocked<IUserRepository>;
 | 
			
		||||
  let cryptoRepositoryMock: jest.Mocked<ICryptoRepository>;
 | 
			
		||||
 | 
			
		||||
  let albumMock: jest.Mocked<IAlbumRepository>;
 | 
			
		||||
  let assetMock: jest.Mocked<IAssetRepository>;
 | 
			
		||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
			
		||||
  let keyMock: jest.Mocked<IKeyRepository>;
 | 
			
		||||
  let storageMock: jest.Mocked<IStorageRepository>;
 | 
			
		||||
  let tokenMock: jest.Mocked<IUserTokenRepository>;
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    userRepositoryMock = newUserRepositoryMock();
 | 
			
		||||
    cryptoRepositoryMock = newCryptoRepositoryMock();
 | 
			
		||||
    sut = new UserService(userRepositoryMock, cryptoRepositoryMock);
 | 
			
		||||
 | 
			
		||||
    albumMock = newAlbumRepositoryMock();
 | 
			
		||||
    assetMock = newAssetRepositoryMock();
 | 
			
		||||
    jobMock = newJobRepositoryMock();
 | 
			
		||||
    keyMock = newKeyRepositoryMock();
 | 
			
		||||
    storageMock = newStorageRepositoryMock();
 | 
			
		||||
    tokenMock = newUserTokenRepositoryMock();
 | 
			
		||||
    userRepositoryMock = newUserRepositoryMock();
 | 
			
		||||
 | 
			
		||||
    sut = new UserService(
 | 
			
		||||
      userRepositoryMock,
 | 
			
		||||
      cryptoRepositoryMock,
 | 
			
		||||
      albumMock,
 | 
			
		||||
      assetMock,
 | 
			
		||||
      jobMock,
 | 
			
		||||
      keyMock,
 | 
			
		||||
      storageMock,
 | 
			
		||||
      tokenMock,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
 | 
			
		||||
    when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);
 | 
			
		||||
@@ -374,4 +420,64 @@ describe(UserService.name, () => {
 | 
			
		||||
      expect(update.password).toBeDefined();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleUserDeleteCheck', () => {
 | 
			
		||||
    it('should skip users not ready for deletion', async () => {
 | 
			
		||||
      userRepositoryMock.getDeletedUsers.mockResolvedValue([
 | 
			
		||||
        {},
 | 
			
		||||
        { deletedAt: undefined },
 | 
			
		||||
        { deletedAt: null },
 | 
			
		||||
        { deletedAt: makeDeletedAt(5) },
 | 
			
		||||
      ] as UserEntity[]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleUserDeleteCheck();
 | 
			
		||||
 | 
			
		||||
      expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
 | 
			
		||||
      expect(jobMock.queue).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should queue user ready for deletion', async () => {
 | 
			
		||||
      const user = { deletedAt: makeDeletedAt(10) };
 | 
			
		||||
      userRepositoryMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleUserDeleteCheck();
 | 
			
		||||
 | 
			
		||||
      expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('handleUserDelete', () => {
 | 
			
		||||
    it('should skip users not ready for deletion', async () => {
 | 
			
		||||
      const user = { deletedAt: makeDeletedAt(5) } as UserEntity;
 | 
			
		||||
 | 
			
		||||
      await sut.handleUserDelete({ user });
 | 
			
		||||
 | 
			
		||||
      expect(storageMock.unlinkDir).not.toHaveBeenCalled();
 | 
			
		||||
      expect(userRepositoryMock.delete).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should delete the user and associated assets', async () => {
 | 
			
		||||
      const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
 | 
			
		||||
 | 
			
		||||
      await sut.handleUserDelete({ user });
 | 
			
		||||
 | 
			
		||||
      expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/deleted-user', { force: true, recursive: true });
 | 
			
		||||
      expect(tokenMock.deleteAll).toHaveBeenCalledWith(user.id);
 | 
			
		||||
      expect(keyMock.deleteAll).toHaveBeenCalledWith(user.id);
 | 
			
		||||
      expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id);
 | 
			
		||||
      expect(assetMock.deleteAll).toHaveBeenCalledWith(user.id);
 | 
			
		||||
      expect(userRepositoryMock.delete).toHaveBeenCalledWith(user, true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle an error', async () => {
 | 
			
		||||
      const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
 | 
			
		||||
 | 
			
		||||
      storageMock.unlinkDir.mockRejectedValue(new Error('Read only filesystem'));
 | 
			
		||||
 | 
			
		||||
      await sut.handleUserDelete({ user });
 | 
			
		||||
 | 
			
		||||
      expect(userRepositoryMock.delete).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,43 @@
 | 
			
		||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
 | 
			
		||||
import { UserEntity } from '@app/infra/db/entities';
 | 
			
		||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 | 
			
		||||
import { randomBytes } from 'crypto';
 | 
			
		||||
import { ReadStream } from 'fs';
 | 
			
		||||
import { join } from 'path';
 | 
			
		||||
import { APP_UPLOAD_LOCATION } from '@app/common';
 | 
			
		||||
import { IAlbumRepository } from '../album/album.repository';
 | 
			
		||||
import { IKeyRepository } from '../api-key/api-key.repository';
 | 
			
		||||
import { IAssetRepository } from '../asset/asset.repository';
 | 
			
		||||
import { AuthUserDto } from '../auth';
 | 
			
		||||
import { ICryptoRepository } from '../crypto';
 | 
			
		||||
import { IUserRepository } from '../user';
 | 
			
		||||
import { CreateUserDto } from './dto/create-user.dto';
 | 
			
		||||
import { UpdateUserDto } from './dto/update-user.dto';
 | 
			
		||||
import { UserCountDto } from './dto/user-count.dto';
 | 
			
		||||
import { ICryptoRepository } from '../crypto/crypto.repository';
 | 
			
		||||
import { IJobRepository, IUserDeletionJob, JobName } from '../job';
 | 
			
		||||
import { IStorageRepository } from '../storage/storage.repository';
 | 
			
		||||
import { IUserTokenRepository } from '../user-token/user-token.repository';
 | 
			
		||||
import { IUserRepository } from '../user/user.repository';
 | 
			
		||||
import { CreateUserDto, UpdateUserDto, UserCountDto } from './dto';
 | 
			
		||||
import {
 | 
			
		||||
  CreateProfileImageResponseDto,
 | 
			
		||||
  mapCreateProfileImageResponse,
 | 
			
		||||
} from './response-dto/create-profile-image-response.dto';
 | 
			
		||||
import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto';
 | 
			
		||||
import { mapUser, UserResponseDto } from './response-dto/user-response.dto';
 | 
			
		||||
  mapUser,
 | 
			
		||||
  mapUserCountResponse,
 | 
			
		||||
  UserCountResponseDto,
 | 
			
		||||
  UserResponseDto,
 | 
			
		||||
} from './response-dto';
 | 
			
		||||
import { UserCore } from './user.core';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class UserService {
 | 
			
		||||
  private logger = new Logger(UserService.name);
 | 
			
		||||
  private userCore: UserCore;
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(IUserRepository) userRepository: IUserRepository,
 | 
			
		||||
    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
			
		||||
    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
 | 
			
		||||
 | 
			
		||||
    @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
 | 
			
		||||
    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
 | 
			
		||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
			
		||||
    @Inject(IKeyRepository) private keyRepository: IKeyRepository,
 | 
			
		||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
			
		||||
    @Inject(IUserTokenRepository) private tokenRepository: IUserTokenRepository,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.userCore = new UserCore(userRepository, cryptoRepository);
 | 
			
		||||
  }
 | 
			
		||||
@@ -123,4 +140,53 @@ export class UserService {
 | 
			
		||||
 | 
			
		||||
    return { admin, password, provided: !!providedPassword };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleUserDeleteCheck() {
 | 
			
		||||
    const users = await this.userRepository.getDeletedUsers();
 | 
			
		||||
    for (const user of users) {
 | 
			
		||||
      if (this.isReadyForDeletion(user)) {
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { user } });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleUserDelete(data: IUserDeletionJob) {
 | 
			
		||||
    const { user } = data;
 | 
			
		||||
 | 
			
		||||
    // just for extra protection here
 | 
			
		||||
    if (!this.isReadyForDeletion(user)) {
 | 
			
		||||
      this.logger.warn(`Skipped user that was not ready for deletion: id=${user.id}`);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.logger.log(`Deleting user: ${user.id}`);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const userAssetDir = join(APP_UPLOAD_LOCATION, user.id);
 | 
			
		||||
      this.logger.warn(`Removing user from filesystem: ${userAssetDir}`);
 | 
			
		||||
      await this.storageRepository.unlinkDir(userAssetDir, { recursive: true, force: true });
 | 
			
		||||
 | 
			
		||||
      this.logger.warn(`Removing user from database: ${user.id}`);
 | 
			
		||||
 | 
			
		||||
      await this.tokenRepository.deleteAll(user.id);
 | 
			
		||||
      await this.keyRepository.deleteAll(user.id);
 | 
			
		||||
      await this.albumRepository.deleteAll(user.id);
 | 
			
		||||
      await this.assetRepository.deleteAll(user.id);
 | 
			
		||||
      await this.userRepository.delete(user, true);
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      this.logger.error(`Failed to remove user`, error, { id: user.id });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private isReadyForDeletion(user: UserEntity): boolean {
 | 
			
		||||
    if (!user.deletedAt) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const msInDay = 86400000;
 | 
			
		||||
    const msDeleteWait = msInDay * 7;
 | 
			
		||||
    const msSinceDelete = new Date().getTime() - (Date.parse(user.deletedAt.toString()) || 0);
 | 
			
		||||
 | 
			
		||||
    return msSinceDelete >= msDeleteWait;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7
									
								
								server/libs/domain/test/album.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/libs/domain/test/album.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import { IAlbumRepository } from '../src';
 | 
			
		||||
 | 
			
		||||
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@@ -5,6 +5,7 @@ export const newKeyRepositoryMock = (): jest.Mocked<IKeyRepository> => {
 | 
			
		||||
    create: jest.fn(),
 | 
			
		||||
    update: jest.fn(),
 | 
			
		||||
    delete: jest.fn(),
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
    getKey: jest.fn(),
 | 
			
		||||
    getById: jest.fn(),
 | 
			
		||||
    getByUserId: jest.fn(),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								server/libs/domain/test/asset.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								server/libs/domain/test/asset.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import { IAssetRepository } from '../src';
 | 
			
		||||
 | 
			
		||||
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    getAll: jest.fn(),
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
    save: jest.fn(),
 | 
			
		||||
    findLivePhotoMatch: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@@ -91,22 +91,37 @@ export const userEntityStub = {
 | 
			
		||||
  }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const fileStub = {
 | 
			
		||||
  livePhotoStill: Object.freeze({
 | 
			
		||||
    originalPath: 'fake_path/asset_1.jpeg',
 | 
			
		||||
    mimeType: 'image/jpg',
 | 
			
		||||
    checksum: Buffer.from('file hash', 'utf8'),
 | 
			
		||||
    originalName: 'asset_1.jpeg',
 | 
			
		||||
  }),
 | 
			
		||||
  livePhotoMotion: Object.freeze({
 | 
			
		||||
    originalPath: 'fake_path/asset_1.mp4',
 | 
			
		||||
    mimeType: 'image/jpeg',
 | 
			
		||||
    checksum: Buffer.from('live photo file hash', 'utf8'),
 | 
			
		||||
    originalName: 'asset_1.mp4',
 | 
			
		||||
  }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const assetEntityStub = {
 | 
			
		||||
  image: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
    fileModifiedAt: today.toISOString(),
 | 
			
		||||
    fileCreatedAt: today.toISOString(),
 | 
			
		||||
    fileModifiedAt: '2023-02-23T05:06:29.716Z',
 | 
			
		||||
    fileCreatedAt: '2023-02-23T05:06:29.716Z',
 | 
			
		||||
    owner: userEntityStub.user1,
 | 
			
		||||
    ownerId: 'user-id',
 | 
			
		||||
    deviceId: 'device-id',
 | 
			
		||||
    originalPath: '/original/path',
 | 
			
		||||
    originalPath: '/original/path.ext',
 | 
			
		||||
    resizePath: null,
 | 
			
		||||
    type: AssetType.IMAGE,
 | 
			
		||||
    webpPath: null,
 | 
			
		||||
    encodedVideoPath: null,
 | 
			
		||||
    createdAt: today.toISOString(),
 | 
			
		||||
    updatedAt: today.toISOString(),
 | 
			
		||||
    createdAt: '2023-02-23T05:06:29.716Z',
 | 
			
		||||
    updatedAt: '2023-02-23T05:06:29.716Z',
 | 
			
		||||
    mimeType: null,
 | 
			
		||||
    isFavorite: true,
 | 
			
		||||
    duration: null,
 | 
			
		||||
@@ -116,6 +131,26 @@ export const assetEntityStub = {
 | 
			
		||||
    tags: [],
 | 
			
		||||
    sharedLinks: [],
 | 
			
		||||
  }),
 | 
			
		||||
  livePhotoMotionAsset: Object.freeze({
 | 
			
		||||
    id: 'live-photo-motion-asset',
 | 
			
		||||
    originalPath: fileStub.livePhotoMotion.originalPath,
 | 
			
		||||
    ownerId: authStub.user1.id,
 | 
			
		||||
    type: AssetType.VIDEO,
 | 
			
		||||
    isVisible: false,
 | 
			
		||||
    fileModifiedAt: '2022-06-19T23:41:36.910Z',
 | 
			
		||||
    fileCreatedAt: '2022-06-19T23:41:36.910Z',
 | 
			
		||||
  } as AssetEntity),
 | 
			
		||||
 | 
			
		||||
  livePhotoStillAsset: Object.freeze({
 | 
			
		||||
    id: 'live-photo-still-asset',
 | 
			
		||||
    originalPath: fileStub.livePhotoStill.originalPath,
 | 
			
		||||
    ownerId: authStub.user1.id,
 | 
			
		||||
    type: AssetType.IMAGE,
 | 
			
		||||
    livePhotoVideoId: 'live-photo-motion-asset',
 | 
			
		||||
    isVisible: true,
 | 
			
		||||
    fileModifiedAt: '2022-06-19T23:41:36.910Z',
 | 
			
		||||
    fileCreatedAt: '2022-06-19T23:41:36.910Z',
 | 
			
		||||
  } as AssetEntity),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const assetInfo: ExifResponseDto = {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,13 @@
 | 
			
		||||
export * from './album.repository.mock';
 | 
			
		||||
export * from './api-key.repository.mock';
 | 
			
		||||
export * from './asset.repository.mock';
 | 
			
		||||
export * from './crypto.repository.mock';
 | 
			
		||||
export * from './device-info.repository.mock';
 | 
			
		||||
export * from './fixtures';
 | 
			
		||||
export * from './job.repository.mock';
 | 
			
		||||
export * from './machine-learning.repository.mock';
 | 
			
		||||
export * from './shared-link.repository.mock';
 | 
			
		||||
export * from './smart-info.repository.mock';
 | 
			
		||||
export * from './storage.repository.mock';
 | 
			
		||||
export * from './system-config.repository.mock';
 | 
			
		||||
export * from './user-token.repository.mock';
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ import { IJobRepository } from '../src';
 | 
			
		||||
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    empty: jest.fn(),
 | 
			
		||||
    add: jest.fn().mockImplementation(() => Promise.resolve()),
 | 
			
		||||
    queue: jest.fn().mockImplementation(() => Promise.resolve()),
 | 
			
		||||
    isActive: jest.fn(),
 | 
			
		||||
    getJobCounts: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,8 @@
 | 
			
		||||
import { IMachineLearningRepository } from '../src';
 | 
			
		||||
 | 
			
		||||
export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearningRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    tagImage: jest.fn(),
 | 
			
		||||
    detectObjects: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										11
									
								
								server/libs/domain/test/setup.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								server/libs/domain/test/setup.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
jest.mock('@nestjs/common', () => ({
 | 
			
		||||
  ...jest.requireActual('@nestjs/common'),
 | 
			
		||||
  Logger: jest.fn().mockReturnValue({
 | 
			
		||||
    verbose: jest.fn(),
 | 
			
		||||
    debug: jest.fn(),
 | 
			
		||||
    log: jest.fn(),
 | 
			
		||||
    info: jest.fn(),
 | 
			
		||||
    warn: jest.fn(),
 | 
			
		||||
    error: jest.fn(),
 | 
			
		||||
  }),
 | 
			
		||||
}));
 | 
			
		||||
							
								
								
									
										7
									
								
								server/libs/domain/test/smart-info.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/libs/domain/test/smart-info.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import { ISmartInfoRepository } from '../src';
 | 
			
		||||
 | 
			
		||||
export const newSmartInfoRepositoryMock = (): jest.Mocked<ISmartInfoRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    upsert: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
@@ -3,5 +3,11 @@ import { IStorageRepository } from '../src';
 | 
			
		||||
export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
 | 
			
		||||
  return {
 | 
			
		||||
    createReadStream: jest.fn(),
 | 
			
		||||
    unlink: jest.fn(),
 | 
			
		||||
    unlinkDir: jest.fn(),
 | 
			
		||||
    removeEmptyDirs: jest.fn(),
 | 
			
		||||
    moveFile: jest.fn(),
 | 
			
		||||
    checkFileExists: jest.fn(),
 | 
			
		||||
    mkdirSync: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository>
 | 
			
		||||
  return {
 | 
			
		||||
    create: jest.fn(),
 | 
			
		||||
    delete: jest.fn(),
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
    get: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
 | 
			
		||||
    create: jest.fn(),
 | 
			
		||||
    update: jest.fn(),
 | 
			
		||||
    delete: jest.fn(),
 | 
			
		||||
    getDeletedUsers: jest.fn(),
 | 
			
		||||
    restore: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user