mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	chore(server) Add job for storage migration (#1117)
This commit is contained in:
		
							
								
								
									
										2
									
								
								mobile/openapi/doc/AllJobStatusResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/AllJobStatusResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -12,10 +12,12 @@ Name | Type | Description | Notes | |||||||
| **metadataExtractionQueueCount** | [**JobCounts**](JobCounts.md) |  |  | **metadataExtractionQueueCount** | [**JobCounts**](JobCounts.md) |  |  | ||||||
| **videoConversionQueueCount** | [**JobCounts**](JobCounts.md) |  |  | **videoConversionQueueCount** | [**JobCounts**](JobCounts.md) |  |  | ||||||
| **machineLearningQueueCount** | [**JobCounts**](JobCounts.md) |  |  | **machineLearningQueueCount** | [**JobCounts**](JobCounts.md) |  |  | ||||||
|  | **storageMigrationQueueCount** | [**JobCounts**](JobCounts.md) |  |  | ||||||
| **isThumbnailGenerationActive** | **bool** |  |  | **isThumbnailGenerationActive** | **bool** |  |  | ||||||
| **isMetadataExtractionActive** | **bool** |  |  | **isMetadataExtractionActive** | **bool** |  |  | ||||||
| **isVideoConversionActive** | **bool** |  |  | **isVideoConversionActive** | **bool** |  |  | ||||||
| **isMachineLearningActive** | **bool** |  |  | **isMachineLearningActive** | **bool** |  |  | ||||||
|  | **isStorageMigrationActive** | **bool** |  |  | ||||||
| 
 | 
 | ||||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||||
| 
 | 
 | ||||||
|   | |||||||
| @@ -17,10 +17,12 @@ class AllJobStatusResponseDto { | |||||||
|     required this.metadataExtractionQueueCount, |     required this.metadataExtractionQueueCount, | ||||||
|     required this.videoConversionQueueCount, |     required this.videoConversionQueueCount, | ||||||
|     required this.machineLearningQueueCount, |     required this.machineLearningQueueCount, | ||||||
|  |     required this.storageMigrationQueueCount, | ||||||
|     required this.isThumbnailGenerationActive, |     required this.isThumbnailGenerationActive, | ||||||
|     required this.isMetadataExtractionActive, |     required this.isMetadataExtractionActive, | ||||||
|     required this.isVideoConversionActive, |     required this.isVideoConversionActive, | ||||||
|     required this.isMachineLearningActive, |     required this.isMachineLearningActive, | ||||||
|  |     required this.isStorageMigrationActive, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   JobCounts thumbnailGenerationQueueCount; |   JobCounts thumbnailGenerationQueueCount; | ||||||
| @@ -31,6 +33,8 @@ class AllJobStatusResponseDto { | |||||||
| 
 | 
 | ||||||
|   JobCounts machineLearningQueueCount; |   JobCounts machineLearningQueueCount; | ||||||
| 
 | 
 | ||||||
|  |   JobCounts storageMigrationQueueCount; | ||||||
|  | 
 | ||||||
|   bool isThumbnailGenerationActive; |   bool isThumbnailGenerationActive; | ||||||
| 
 | 
 | ||||||
|   bool isMetadataExtractionActive; |   bool isMetadataExtractionActive; | ||||||
| @@ -39,16 +43,20 @@ class AllJobStatusResponseDto { | |||||||
| 
 | 
 | ||||||
|   bool isMachineLearningActive; |   bool isMachineLearningActive; | ||||||
| 
 | 
 | ||||||
|  |   bool isStorageMigrationActive; | ||||||
|  | 
 | ||||||
|   @override |   @override | ||||||
|   bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && |   bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && | ||||||
|      other.thumbnailGenerationQueueCount == thumbnailGenerationQueueCount && |      other.thumbnailGenerationQueueCount == thumbnailGenerationQueueCount && | ||||||
|      other.metadataExtractionQueueCount == metadataExtractionQueueCount && |      other.metadataExtractionQueueCount == metadataExtractionQueueCount && | ||||||
|      other.videoConversionQueueCount == videoConversionQueueCount && |      other.videoConversionQueueCount == videoConversionQueueCount && | ||||||
|      other.machineLearningQueueCount == machineLearningQueueCount && |      other.machineLearningQueueCount == machineLearningQueueCount && | ||||||
|  |      other.storageMigrationQueueCount == storageMigrationQueueCount && | ||||||
|      other.isThumbnailGenerationActive == isThumbnailGenerationActive && |      other.isThumbnailGenerationActive == isThumbnailGenerationActive && | ||||||
|      other.isMetadataExtractionActive == isMetadataExtractionActive && |      other.isMetadataExtractionActive == isMetadataExtractionActive && | ||||||
|      other.isVideoConversionActive == isVideoConversionActive && |      other.isVideoConversionActive == isVideoConversionActive && | ||||||
|      other.isMachineLearningActive == isMachineLearningActive; |      other.isMachineLearningActive == isMachineLearningActive && | ||||||
|  |      other.isStorageMigrationActive == isStorageMigrationActive; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   int get hashCode => |   int get hashCode => | ||||||
| @@ -57,13 +65,15 @@ class AllJobStatusResponseDto { | |||||||
|     (metadataExtractionQueueCount.hashCode) + |     (metadataExtractionQueueCount.hashCode) + | ||||||
|     (videoConversionQueueCount.hashCode) + |     (videoConversionQueueCount.hashCode) + | ||||||
|     (machineLearningQueueCount.hashCode) + |     (machineLearningQueueCount.hashCode) + | ||||||
|  |     (storageMigrationQueueCount.hashCode) + | ||||||
|     (isThumbnailGenerationActive.hashCode) + |     (isThumbnailGenerationActive.hashCode) + | ||||||
|     (isMetadataExtractionActive.hashCode) + |     (isMetadataExtractionActive.hashCode) + | ||||||
|     (isVideoConversionActive.hashCode) + |     (isVideoConversionActive.hashCode) + | ||||||
|     (isMachineLearningActive.hashCode); |     (isMachineLearningActive.hashCode) + | ||||||
|  |     (isStorageMigrationActive.hashCode); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueueCount=$thumbnailGenerationQueueCount, metadataExtractionQueueCount=$metadataExtractionQueueCount, videoConversionQueueCount=$videoConversionQueueCount, machineLearningQueueCount=$machineLearningQueueCount, isThumbnailGenerationActive=$isThumbnailGenerationActive, isMetadataExtractionActive=$isMetadataExtractionActive, isVideoConversionActive=$isVideoConversionActive, isMachineLearningActive=$isMachineLearningActive]'; |   String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueueCount=$thumbnailGenerationQueueCount, metadataExtractionQueueCount=$metadataExtractionQueueCount, videoConversionQueueCount=$videoConversionQueueCount, machineLearningQueueCount=$machineLearningQueueCount, storageMigrationQueueCount=$storageMigrationQueueCount, isThumbnailGenerationActive=$isThumbnailGenerationActive, isMetadataExtractionActive=$isMetadataExtractionActive, isVideoConversionActive=$isVideoConversionActive, isMachineLearningActive=$isMachineLearningActive, isStorageMigrationActive=$isStorageMigrationActive]'; | ||||||
| 
 | 
 | ||||||
|   Map<String, dynamic> toJson() { |   Map<String, dynamic> toJson() { | ||||||
|     final _json = <String, dynamic>{}; |     final _json = <String, dynamic>{}; | ||||||
| @@ -71,10 +81,12 @@ class AllJobStatusResponseDto { | |||||||
|       _json[r'metadataExtractionQueueCount'] = metadataExtractionQueueCount; |       _json[r'metadataExtractionQueueCount'] = metadataExtractionQueueCount; | ||||||
|       _json[r'videoConversionQueueCount'] = videoConversionQueueCount; |       _json[r'videoConversionQueueCount'] = videoConversionQueueCount; | ||||||
|       _json[r'machineLearningQueueCount'] = machineLearningQueueCount; |       _json[r'machineLearningQueueCount'] = machineLearningQueueCount; | ||||||
|  |       _json[r'storageMigrationQueueCount'] = storageMigrationQueueCount; | ||||||
|       _json[r'isThumbnailGenerationActive'] = isThumbnailGenerationActive; |       _json[r'isThumbnailGenerationActive'] = isThumbnailGenerationActive; | ||||||
|       _json[r'isMetadataExtractionActive'] = isMetadataExtractionActive; |       _json[r'isMetadataExtractionActive'] = isMetadataExtractionActive; | ||||||
|       _json[r'isVideoConversionActive'] = isVideoConversionActive; |       _json[r'isVideoConversionActive'] = isVideoConversionActive; | ||||||
|       _json[r'isMachineLearningActive'] = isMachineLearningActive; |       _json[r'isMachineLearningActive'] = isMachineLearningActive; | ||||||
|  |       _json[r'isStorageMigrationActive'] = isStorageMigrationActive; | ||||||
|     return _json; |     return _json; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @@ -101,10 +113,12 @@ class AllJobStatusResponseDto { | |||||||
|         metadataExtractionQueueCount: JobCounts.fromJson(json[r'metadataExtractionQueueCount'])!, |         metadataExtractionQueueCount: JobCounts.fromJson(json[r'metadataExtractionQueueCount'])!, | ||||||
|         videoConversionQueueCount: JobCounts.fromJson(json[r'videoConversionQueueCount'])!, |         videoConversionQueueCount: JobCounts.fromJson(json[r'videoConversionQueueCount'])!, | ||||||
|         machineLearningQueueCount: JobCounts.fromJson(json[r'machineLearningQueueCount'])!, |         machineLearningQueueCount: JobCounts.fromJson(json[r'machineLearningQueueCount'])!, | ||||||
|  |         storageMigrationQueueCount: JobCounts.fromJson(json[r'storageMigrationQueueCount'])!, | ||||||
|         isThumbnailGenerationActive: mapValueOfType<bool>(json, r'isThumbnailGenerationActive')!, |         isThumbnailGenerationActive: mapValueOfType<bool>(json, r'isThumbnailGenerationActive')!, | ||||||
|         isMetadataExtractionActive: mapValueOfType<bool>(json, r'isMetadataExtractionActive')!, |         isMetadataExtractionActive: mapValueOfType<bool>(json, r'isMetadataExtractionActive')!, | ||||||
|         isVideoConversionActive: mapValueOfType<bool>(json, r'isVideoConversionActive')!, |         isVideoConversionActive: mapValueOfType<bool>(json, r'isVideoConversionActive')!, | ||||||
|         isMachineLearningActive: mapValueOfType<bool>(json, r'isMachineLearningActive')!, |         isMachineLearningActive: mapValueOfType<bool>(json, r'isMachineLearningActive')!, | ||||||
|  |         isStorageMigrationActive: mapValueOfType<bool>(json, r'isStorageMigrationActive')!, | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|     return null; |     return null; | ||||||
| @@ -158,10 +172,12 @@ class AllJobStatusResponseDto { | |||||||
|     'metadataExtractionQueueCount', |     'metadataExtractionQueueCount', | ||||||
|     'videoConversionQueueCount', |     'videoConversionQueueCount', | ||||||
|     'machineLearningQueueCount', |     'machineLearningQueueCount', | ||||||
|  |     'storageMigrationQueueCount', | ||||||
|     'isThumbnailGenerationActive', |     'isThumbnailGenerationActive', | ||||||
|     'isMetadataExtractionActive', |     'isMetadataExtractionActive', | ||||||
|     'isVideoConversionActive', |     'isVideoConversionActive', | ||||||
|     'isMachineLearningActive', |     'isMachineLearningActive', | ||||||
|  |     'isStorageMigrationActive', | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/model/job_id.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/model/job_id.dart
									
									
									
										generated
									
									
									
								
							| @@ -27,6 +27,7 @@ class JobId { | |||||||
|   static const metadataExtraction = JobId._(r'metadata-extraction'); |   static const metadataExtraction = JobId._(r'metadata-extraction'); | ||||||
|   static const videoConversion = JobId._(r'video-conversion'); |   static const videoConversion = JobId._(r'video-conversion'); | ||||||
|   static const machineLearning = JobId._(r'machine-learning'); |   static const machineLearning = JobId._(r'machine-learning'); | ||||||
|  |   static const storageTemplateMigration = JobId._(r'storage-template-migration'); | ||||||
| 
 | 
 | ||||||
|   /// List of all possible values in this [enum][JobId]. |   /// List of all possible values in this [enum][JobId]. | ||||||
|   static const values = <JobId>[ |   static const values = <JobId>[ | ||||||
| @@ -34,6 +35,7 @@ class JobId { | |||||||
|     metadataExtraction, |     metadataExtraction, | ||||||
|     videoConversion, |     videoConversion, | ||||||
|     machineLearning, |     machineLearning, | ||||||
|  |     storageTemplateMigration, | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   static JobId? fromJson(dynamic value) => JobIdTypeTransformer().decode(value); |   static JobId? fromJson(dynamic value) => JobIdTypeTransformer().decode(value); | ||||||
| @@ -76,6 +78,7 @@ class JobIdTypeTransformer { | |||||||
|         case r'metadata-extraction': return JobId.metadataExtraction; |         case r'metadata-extraction': return JobId.metadataExtraction; | ||||||
|         case r'video-conversion': return JobId.videoConversion; |         case r'video-conversion': return JobId.videoConversion; | ||||||
|         case r'machine-learning': return JobId.machineLearning; |         case r'machine-learning': return JobId.machineLearning; | ||||||
|  |         case r'storage-template-migration': return JobId.storageTemplateMigration; | ||||||
|         default: |         default: | ||||||
|           if (!allowNull) { |           if (!allowNull) { | ||||||
|             throw ArgumentError('Unknown enum value to decode: $data'); |             throw ArgumentError('Unknown enum value to decode: $data'); | ||||||
|   | |||||||
| @@ -36,6 +36,11 @@ void main() { | |||||||
|       // TODO |       // TODO | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     // JobCounts storageMigrationQueueCount | ||||||
|  |     test('to test the property `storageMigrationQueueCount`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     // bool isThumbnailGenerationActive |     // bool isThumbnailGenerationActive | ||||||
|     test('to test the property `isThumbnailGenerationActive`', () async { |     test('to test the property `isThumbnailGenerationActive`', () async { | ||||||
|       // TODO |       // TODO | ||||||
| @@ -56,6 +61,11 @@ void main() { | |||||||
|       // TODO |       // TODO | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     // bool isStorageMigrationActive | ||||||
|  |     test('to test the property `isStorageMigrationActive`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-as | |||||||
| import { In } from 'typeorm/find-options/operator/In'; | import { In } from 'typeorm/find-options/operator/In'; | ||||||
| import { UpdateAssetDto } from './dto/update-asset.dto'; | import { UpdateAssetDto } from './dto/update-asset.dto'; | ||||||
| import { ITagRepository, TAG_REPOSITORY } from '../tag/tag.repository'; | import { ITagRepository, TAG_REPOSITORY } from '../tag/tag.repository'; | ||||||
|  | import { IsNull } from 'typeorm'; | ||||||
|  |  | ||||||
| export interface IAssetRepository { | export interface IAssetRepository { | ||||||
|   create( |   create( | ||||||
| @@ -69,14 +70,14 @@ export class AssetRepository implements IAssetRepository { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   async getAssetWithNoThumbnail(): Promise<AssetEntity[]> { |   async getAssetWithNoThumbnail(): Promise<AssetEntity[]> { | ||||||
|     return await this.assetRepository |     return await this.assetRepository.find({ | ||||||
|       .createQueryBuilder('asset') |       where: [ | ||||||
|       .where('asset.resizePath IS NULL') |         { resizePath: IsNull(), isVisible: true }, | ||||||
|       .andWhere('asset.isVisible = true') |         { resizePath: '', isVisible: true }, | ||||||
|       .orWhere('asset.resizePath = :resizePath', { resizePath: '' }) |         { webpPath: IsNull(), isVisible: true }, | ||||||
|       .orWhere('asset.webpPath IS NULL') |         { webpPath: '', isVisible: true }, | ||||||
|       .orWhere('asset.webpPath = :webpPath', { webpPath: '' }) |       ], | ||||||
|       .getMany(); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async getAssetWithNoEXIF(): Promise<AssetEntity[]> { |   async getAssetWithNoEXIF(): Promise<AssetEntity[]> { | ||||||
|   | |||||||
| @@ -7,13 +7,13 @@ import { BullModule } from '@nestjs/bull'; | |||||||
| import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; | import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; | ||||||
| import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; | import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; | ||||||
| import { CommunicationModule } from '../communication/communication.module'; | import { CommunicationModule } from '../communication/communication.module'; | ||||||
| import { QueueNameEnum } from '@app/job/constants/queue-name.constant'; |  | ||||||
| import { AssetRepository, ASSET_REPOSITORY } from './asset-repository'; | import { AssetRepository, ASSET_REPOSITORY } from './asset-repository'; | ||||||
| import { DownloadModule } from '../../modules/download/download.module'; | import { DownloadModule } from '../../modules/download/download.module'; | ||||||
| import { TagModule } from '../tag/tag.module'; | import { TagModule } from '../tag/tag.module'; | ||||||
| import { AlbumModule } from '../album/album.module'; | import { AlbumModule } from '../album/album.module'; | ||||||
| import { UserModule } from '../user/user.module'; | import { UserModule } from '../user/user.module'; | ||||||
| import { StorageModule } from '@app/storage'; | import { StorageModule } from '@app/storage'; | ||||||
|  | import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; | ||||||
|  |  | ||||||
| const ASSET_REPOSITORY_PROVIDER = { | const ASSET_REPOSITORY_PROVIDER = { | ||||||
|   provide: ASSET_REPOSITORY, |   provide: ASSET_REPOSITORY, | ||||||
| @@ -31,22 +31,7 @@ const ASSET_REPOSITORY_PROVIDER = { | |||||||
|     TagModule, |     TagModule, | ||||||
|     StorageModule, |     StorageModule, | ||||||
|     forwardRef(() => AlbumModule), |     forwardRef(() => AlbumModule), | ||||||
|     BullModule.registerQueue({ |     BullModule.registerQueue(...immichSharedQueues), | ||||||
|       name: QueueNameEnum.ASSET_UPLOADED, |  | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|     BullModule.registerQueue({ |  | ||||||
|       name: QueueNameEnum.VIDEO_CONVERSION, |  | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|   ], |   ], | ||||||
|   controllers: [AssetController], |   controllers: [AssetController], | ||||||
|   providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER], |   providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER], | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ export enum JobId { | |||||||
|   METADATA_EXTRACTION = 'metadata-extraction', |   METADATA_EXTRACTION = 'metadata-extraction', | ||||||
|   VIDEO_CONVERSION = 'video-conversion', |   VIDEO_CONVERSION = 'video-conversion', | ||||||
|   MACHINE_LEARNING = 'machine-learning', |   MACHINE_LEARNING = 'machine-learning', | ||||||
|  |   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', | ||||||
| } | } | ||||||
|  |  | ||||||
| export class GetJobDto { | export class GetJobDto { | ||||||
|   | |||||||
| @@ -6,13 +6,15 @@ import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; | |||||||
| import { JwtModule } from '@nestjs/jwt'; | import { JwtModule } from '@nestjs/jwt'; | ||||||
| import { jwtConfig } from '../../config/jwt.config'; | import { jwtConfig } from '../../config/jwt.config'; | ||||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | import { TypeOrmModule } from '@nestjs/typeorm'; | ||||||
| import { BullModule } from '@nestjs/bull'; |  | ||||||
| import { QueueNameEnum } from '@app/job'; |  | ||||||
| import { ExifEntity } from '@app/database/entities/exif.entity'; | import { ExifEntity } from '@app/database/entities/exif.entity'; | ||||||
| import { TagModule } from '../tag/tag.module'; | import { TagModule } from '../tag/tag.module'; | ||||||
| import { AssetModule } from '../asset/asset.module'; | import { AssetModule } from '../asset/asset.module'; | ||||||
| import { UserModule } from '../user/user.module'; | import { UserModule } from '../user/user.module'; | ||||||
|  |  | ||||||
|  | import { StorageModule } from '@app/storage'; | ||||||
|  | import { BullModule } from '@nestjs/bull'; | ||||||
|  | import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; | ||||||
|  |  | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [ |   imports: [ | ||||||
|     TypeOrmModule.forFeature([ExifEntity]), |     TypeOrmModule.forFeature([ExifEntity]), | ||||||
| @@ -21,56 +23,8 @@ import { UserModule } from '../user/user.module'; | |||||||
|     AssetModule, |     AssetModule, | ||||||
|     UserModule, |     UserModule, | ||||||
|     JwtModule.register(jwtConfig), |     JwtModule.register(jwtConfig), | ||||||
|     BullModule.registerQueue( |     StorageModule, | ||||||
|       { |     BullModule.registerQueue(...immichSharedQueues), | ||||||
|         name: QueueNameEnum.THUMBNAIL_GENERATION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.ASSET_UPLOADED, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.METADATA_EXTRACTION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.VIDEO_CONVERSION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.CHECKSUM_GENERATION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.MACHINE_LEARNING, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     ), |  | ||||||
|   ], |   ], | ||||||
|   controllers: [JobController], |   controllers: [JobController], | ||||||
|   providers: [JobService, ImmichJwtService], |   providers: [JobService, ImmichJwtService], | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ import { | |||||||
|   IVideoTranscodeJob, |   IVideoTranscodeJob, | ||||||
|   MachineLearningJobNameEnum, |   MachineLearningJobNameEnum, | ||||||
|   QueueNameEnum, |   QueueNameEnum, | ||||||
|  |   templateMigrationProcessorName, | ||||||
|   videoMetadataExtractionProcessorName, |   videoMetadataExtractionProcessorName, | ||||||
| } from '@app/job'; | } from '@app/job'; | ||||||
| import { InjectQueue } from '@nestjs/bull'; | import { InjectQueue } from '@nestjs/bull'; | ||||||
| @@ -18,6 +19,7 @@ import { AssetType } from '@app/database/entities/asset.entity'; | |||||||
| import { GetJobDto, JobId } from './dto/get-job.dto'; | import { GetJobDto, JobId } from './dto/get-job.dto'; | ||||||
| import { JobStatusResponseDto } from './response-dto/job-status-response.dto'; | import { JobStatusResponseDto } from './response-dto/job-status-response.dto'; | ||||||
| import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface'; | import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface'; | ||||||
|  | import { StorageService } from '@app/storage'; | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class JobService { | export class JobService { | ||||||
| @@ -34,12 +36,18 @@ export class JobService { | |||||||
|     @InjectQueue(QueueNameEnum.MACHINE_LEARNING) |     @InjectQueue(QueueNameEnum.MACHINE_LEARNING) | ||||||
|     private machineLearningQueue: Queue<IMachineLearningJob>, |     private machineLearningQueue: Queue<IMachineLearningJob>, | ||||||
|  |  | ||||||
|  |     @InjectQueue(QueueNameEnum.STORAGE_MIGRATION) | ||||||
|  |     private storageMigrationQueue: Queue, | ||||||
|  |  | ||||||
|     @Inject(ASSET_REPOSITORY) |     @Inject(ASSET_REPOSITORY) | ||||||
|     private _assetRepository: IAssetRepository, |     private _assetRepository: IAssetRepository, | ||||||
|  |  | ||||||
|  |     private storageService: StorageService, | ||||||
|   ) { |   ) { | ||||||
|     this.thumbnailGeneratorQueue.empty(); |     this.thumbnailGeneratorQueue.empty(); | ||||||
|     this.metadataExtractionQueue.empty(); |     this.metadataExtractionQueue.empty(); | ||||||
|     this.videoConversionQueue.empty(); |     this.videoConversionQueue.empty(); | ||||||
|  |     this.storageMigrationQueue.empty(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async startJob(jobDto: GetJobDto): Promise<number> { |   async startJob(jobDto: GetJobDto): Promise<number> { | ||||||
| @@ -52,6 +60,8 @@ export class JobService { | |||||||
|         return 0; |         return 0; | ||||||
|       case JobId.MACHINE_LEARNING: |       case JobId.MACHINE_LEARNING: | ||||||
|         return this.runMachineLearningPipeline(); |         return this.runMachineLearningPipeline(); | ||||||
|  |       case JobId.STORAGE_TEMPLATE_MIGRATION: | ||||||
|  |         return this.runStorageMigration(); | ||||||
|       default: |       default: | ||||||
|         throw new BadRequestException('Invalid job id'); |         throw new BadRequestException('Invalid job id'); | ||||||
|     } |     } | ||||||
| @@ -62,6 +72,7 @@ export class JobService { | |||||||
|     const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts(); |     const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts(); | ||||||
|     const videoConversionJobCount = await this.videoConversionQueue.getJobCounts(); |     const videoConversionJobCount = await this.videoConversionQueue.getJobCounts(); | ||||||
|     const machineLearningJobCount = await this.machineLearningQueue.getJobCounts(); |     const machineLearningJobCount = await this.machineLearningQueue.getJobCounts(); | ||||||
|  |     const storageMigrationJobCount = await this.storageMigrationQueue.getJobCounts(); | ||||||
|  |  | ||||||
|     const response = new AllJobStatusResponseDto(); |     const response = new AllJobStatusResponseDto(); | ||||||
|     response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting); |     response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting); | ||||||
| @@ -73,6 +84,9 @@ export class JobService { | |||||||
|     response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting); |     response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting); | ||||||
|     response.machineLearningQueueCount = machineLearningJobCount; |     response.machineLearningQueueCount = machineLearningJobCount; | ||||||
|  |  | ||||||
|  |     response.isStorageMigrationActive = Boolean(storageMigrationJobCount.active); | ||||||
|  |     response.storageMigrationQueueCount = storageMigrationJobCount; | ||||||
|  |  | ||||||
|     return response; |     return response; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -93,6 +107,11 @@ export class JobService { | |||||||
|       response.queueCount = await this.videoConversionQueue.getJobCounts(); |       response.queueCount = await this.videoConversionQueue.getJobCounts(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (query.jobId === JobId.STORAGE_TEMPLATE_MIGRATION) { | ||||||
|  |       response.isActive = Boolean((await this.storageMigrationQueue.getJobCounts()).waiting); | ||||||
|  |       response.queueCount = await this.storageMigrationQueue.getJobCounts(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return response; |     return response; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -110,6 +129,9 @@ export class JobService { | |||||||
|       case JobId.MACHINE_LEARNING: |       case JobId.MACHINE_LEARNING: | ||||||
|         this.machineLearningQueue.empty(); |         this.machineLearningQueue.empty(); | ||||||
|         return 0; |         return 0; | ||||||
|  |       case JobId.STORAGE_TEMPLATE_MIGRATION: | ||||||
|  |         this.storageMigrationQueue.empty(); | ||||||
|  |         return 0; | ||||||
|       default: |       default: | ||||||
|         throw new BadRequestException('Invalid job id'); |         throw new BadRequestException('Invalid job id'); | ||||||
|     } |     } | ||||||
| @@ -177,4 +199,16 @@ export class JobService { | |||||||
|  |  | ||||||
|     return assetWithNoSmartInfo.length; |     return assetWithNoSmartInfo.length; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async runStorageMigration() { | ||||||
|  |     const jobCount = await this.storageMigrationQueue.getJobCounts(); | ||||||
|  |  | ||||||
|  |     if (jobCount.active > 0) { | ||||||
|  |       throw new BadRequestException('Storage migration job is already running'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await this.storageMigrationQueue.add(templateMigrationProcessorName, {}, { jobId: randomUUID() }); | ||||||
|  |  | ||||||
|  |     return 1; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ export class AllJobStatusResponseDto { | |||||||
|   isMetadataExtractionActive!: boolean; |   isMetadataExtractionActive!: boolean; | ||||||
|   isVideoConversionActive!: boolean; |   isVideoConversionActive!: boolean; | ||||||
|   isMachineLearningActive!: boolean; |   isMachineLearningActive!: boolean; | ||||||
|  |   isStorageMigrationActive!: boolean; | ||||||
|  |  | ||||||
|   @ApiProperty({ |   @ApiProperty({ | ||||||
|     type: JobCounts, |     type: JobCounts, | ||||||
| @@ -37,4 +38,9 @@ export class AllJobStatusResponseDto { | |||||||
|     type: JobCounts, |     type: JobCounts, | ||||||
|   }) |   }) | ||||||
|   machineLearningQueueCount!: JobCounts; |   machineLearningQueueCount!: JobCounts; | ||||||
|  |  | ||||||
|  |   @ApiProperty({ | ||||||
|  |     type: JobCounts, | ||||||
|  |   }) | ||||||
|  |   storageMigrationQueueCount!: JobCounts; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,6 @@ | |||||||
| import { SystemConfigEntity } from '@app/database/entities/system-config.entity'; | import { SystemConfigEntity } from '@app/database/entities/system-config.entity'; | ||||||
|  | import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; | ||||||
|  | import { BullModule } from '@nestjs/bull'; | ||||||
| import { Module } from '@nestjs/common'; | import { Module } from '@nestjs/common'; | ||||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | import { TypeOrmModule } from '@nestjs/typeorm'; | ||||||
| import { ImmichConfigModule } from 'libs/immich-config/src'; | import { ImmichConfigModule } from 'libs/immich-config/src'; | ||||||
| @@ -7,7 +9,12 @@ import { SystemConfigController } from './system-config.controller'; | |||||||
| import { SystemConfigService } from './system-config.service'; | import { SystemConfigService } from './system-config.service'; | ||||||
|  |  | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [ImmichJwtModule, ImmichConfigModule, TypeOrmModule.forFeature([SystemConfigEntity])], |   imports: [ | ||||||
|  |     ImmichJwtModule, | ||||||
|  |     ImmichConfigModule, | ||||||
|  |     TypeOrmModule.forFeature([SystemConfigEntity]), | ||||||
|  |     BullModule.registerQueue(...immichSharedQueues), | ||||||
|  |   ], | ||||||
|   controllers: [SystemConfigController], |   controllers: [SystemConfigController], | ||||||
|   providers: [SystemConfigService], |   providers: [SystemConfigService], | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import { QueueNameEnum, updateTemplateProcessorName } from '@app/job'; | ||||||
| import { | import { | ||||||
|   supportedDayTokens, |   supportedDayTokens, | ||||||
|   supportedHourTokens, |   supportedHourTokens, | ||||||
| @@ -7,14 +8,21 @@ import { | |||||||
|   supportedSecondTokens, |   supportedSecondTokens, | ||||||
|   supportedYearTokens, |   supportedYearTokens, | ||||||
| } from '@app/storage/constants/supported-datetime-template'; | } from '@app/storage/constants/supported-datetime-template'; | ||||||
|  | import { InjectQueue } from '@nestjs/bull'; | ||||||
| import { Injectable } from '@nestjs/common'; | import { Injectable } from '@nestjs/common'; | ||||||
|  | import { Queue } from 'bull'; | ||||||
|  | import { randomUUID } from 'crypto'; | ||||||
| import { ImmichConfigService } from 'libs/immich-config/src'; | import { ImmichConfigService } from 'libs/immich-config/src'; | ||||||
| import { mapConfig, SystemConfigDto } from './dto/system-config.dto'; | import { mapConfig, SystemConfigDto } from './dto/system-config.dto'; | ||||||
| import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto'; | import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto'; | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class SystemConfigService { | export class SystemConfigService { | ||||||
|   constructor(private immichConfigService: ImmichConfigService) {} |   constructor( | ||||||
|  |     private immichConfigService: ImmichConfigService, | ||||||
|  |     @InjectQueue(QueueNameEnum.STORAGE_MIGRATION) | ||||||
|  |     private storageMigrationQueue: Queue, | ||||||
|  |   ) {} | ||||||
|  |  | ||||||
|   public async getConfig(): Promise<SystemConfigDto> { |   public async getConfig(): Promise<SystemConfigDto> { | ||||||
|     const config = await this.immichConfigService.getConfig(); |     const config = await this.immichConfigService.getConfig(); | ||||||
| @@ -28,6 +36,7 @@ export class SystemConfigService { | |||||||
|  |  | ||||||
|   public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> { |   public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> { | ||||||
|     const config = await this.immichConfigService.updateConfig(dto); |     const config = await this.immichConfigService.updateConfig(dto); | ||||||
|  |     this.storageMigrationQueue.add(updateTemplateProcessorName, {}, { jobId: randomUUID() }); | ||||||
|     return mapConfig(config); |     return mapConfig(config); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ import { | |||||||
|   UnauthorizedException, |   UnauthorizedException, | ||||||
| } from '@nestjs/common'; | } from '@nestjs/common'; | ||||||
| import { Response as Res } from 'express'; | import { Response as Res } from 'express'; | ||||||
| import { createReadStream } from 'fs'; | import { constants, createReadStream } from 'fs'; | ||||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||||
| import { CreateUserDto } from './dto/create-user.dto'; | import { CreateUserDto } from './dto/create-user.dto'; | ||||||
| import { UpdateUserDto } from './dto/update-user.dto'; | import { UpdateUserDto } from './dto/update-user.dto'; | ||||||
| @@ -22,6 +22,7 @@ import { | |||||||
| import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto'; | import { mapUserCountResponse, UserCountResponseDto } from './response-dto/user-count-response.dto'; | ||||||
| import { mapUser, UserResponseDto } from './response-dto/user-response.dto'; | import { mapUser, UserResponseDto } from './response-dto/user-response.dto'; | ||||||
| import { IUserRepository, USER_REPOSITORY } from './user-repository'; | import { IUserRepository, USER_REPOSITORY } from './user-repository'; | ||||||
|  | import fs from 'fs/promises'; | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class UserService { | export class UserService { | ||||||
| @@ -196,6 +197,8 @@ export class UserService { | |||||||
|         throw new NotFoundException('User does not have a profile image'); |         throw new NotFoundException('User does not have a profile image'); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       await fs.access(user.profileImagePath, constants.R_OK | constants.W_OK); | ||||||
|  |  | ||||||
|       res.set({ |       res.set({ | ||||||
|         'Content-Type': 'image/jpeg', |         'Content-Type': 'image/jpeg', | ||||||
|       }); |       }); | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { immichAppConfig } from '@app/common/config'; | import { immichAppConfig, immichBullAsyncConfig } from '@app/common/config'; | ||||||
| import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; | ||||||
| import { UserModule } from './api-v1/user/user.module'; | import { UserModule } from './api-v1/user/user.module'; | ||||||
| import { AssetModule } from './api-v1/asset/asset.module'; | import { AssetModule } from './api-v1/asset/asset.module'; | ||||||
| @@ -36,18 +36,7 @@ import { TagModule } from './api-v1/tag/tag.module'; | |||||||
|  |  | ||||||
|     DeviceInfoModule, |     DeviceInfoModule, | ||||||
|  |  | ||||||
|     BullModule.forRootAsync({ |     BullModule.forRootAsync(immichBullAsyncConfig), | ||||||
|       useFactory: async () => ({ |  | ||||||
|         prefix: 'immich_bull', |  | ||||||
|         redis: { |  | ||||||
|           host: process.env.REDIS_HOSTNAME || 'immich_redis', |  | ||||||
|           port: parseInt(process.env.REDIS_PORT || '6379'), |  | ||||||
|           db: parseInt(process.env.REDIS_DBINDEX || '0'), |  | ||||||
|           password: process.env.REDIS_PASSWORD || undefined, |  | ||||||
|           path: process.env.REDIS_SOCKET || undefined, |  | ||||||
|         }, |  | ||||||
|       }), |  | ||||||
|     }), |  | ||||||
|  |  | ||||||
|     ServerInfoModule, |     ServerInfoModule, | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,11 +11,6 @@ import { BackgroundTaskService } from './background-task.service'; | |||||||
|   imports: [ |   imports: [ | ||||||
|     BullModule.registerQueue({ |     BullModule.registerQueue({ | ||||||
|       name: 'background-task', |       name: 'background-task', | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |     }), | ||||||
|     TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]), |     TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]), | ||||||
|   ], |   ], | ||||||
|   | |||||||
| @@ -3,46 +3,14 @@ import { Module } from '@nestjs/common'; | |||||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | import { TypeOrmModule } from '@nestjs/typeorm'; | ||||||
| import { AssetEntity } from '@app/database/entities/asset.entity'; | import { AssetEntity } from '@app/database/entities/asset.entity'; | ||||||
| import { ScheduleTasksService } from './schedule-tasks.service'; | import { ScheduleTasksService } from './schedule-tasks.service'; | ||||||
| import { QueueNameEnum } from '@app/job/constants/queue-name.constant'; |  | ||||||
| import { ExifEntity } from '@app/database/entities/exif.entity'; | import { ExifEntity } from '@app/database/entities/exif.entity'; | ||||||
| import { UserEntity } from '@app/database/entities/user.entity'; | import { UserEntity } from '@app/database/entities/user.entity'; | ||||||
|  | import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; | ||||||
|  |  | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [ |   imports: [ | ||||||
|     TypeOrmModule.forFeature([AssetEntity, ExifEntity, UserEntity]), |     TypeOrmModule.forFeature([AssetEntity, ExifEntity, UserEntity]), | ||||||
|     BullModule.registerQueue({ |     BullModule.registerQueue(...immichSharedQueues), | ||||||
|       name: QueueNameEnum.USER_DELETION, |  | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|     BullModule.registerQueue({ |  | ||||||
|       name: QueueNameEnum.VIDEO_CONVERSION, |  | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|     BullModule.registerQueue({ |  | ||||||
|       name: QueueNameEnum.THUMBNAIL_GENERATION, |  | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|  |  | ||||||
|     BullModule.registerQueue({ |  | ||||||
|       name: QueueNameEnum.METADATA_EXTRACTION, |  | ||||||
|       defaultJobOptions: { |  | ||||||
|         attempts: 3, |  | ||||||
|         removeOnComplete: true, |  | ||||||
|         removeOnFail: false, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|   ], |   ], | ||||||
|   providers: [ScheduleTasksService], |   providers: [ScheduleTasksService], | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -1,10 +1,10 @@ | |||||||
| import { immichAppConfig } from '@app/common/config'; | import { immichAppConfig, immichBullAsyncConfig } from '@app/common/config'; | ||||||
| import { DatabaseModule } from '@app/database'; | import { DatabaseModule } from '@app/database'; | ||||||
| import { AssetEntity } from '@app/database/entities/asset.entity'; | import { AssetEntity } from '@app/database/entities/asset.entity'; | ||||||
| import { ExifEntity } from '@app/database/entities/exif.entity'; | import { ExifEntity } from '@app/database/entities/exif.entity'; | ||||||
| import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; | import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; | ||||||
| import { UserEntity } from '@app/database/entities/user.entity'; | import { UserEntity } from '@app/database/entities/user.entity'; | ||||||
| import { QueueNameEnum } from '@app/job/constants/queue-name.constant'; | import { StorageModule } from '@app/storage'; | ||||||
| import { BullModule } from '@nestjs/bull'; | import { BullModule } from '@nestjs/bull'; | ||||||
| import { Module } from '@nestjs/common'; | import { Module } from '@nestjs/common'; | ||||||
| import { ConfigModule } from '@nestjs/config'; | import { ConfigModule } from '@nestjs/config'; | ||||||
| @@ -16,9 +16,11 @@ import { AssetUploadedProcessor } from './processors/asset-uploaded.processor'; | |||||||
| import { GenerateChecksumProcessor } from './processors/generate-checksum.processor'; | import { GenerateChecksumProcessor } from './processors/generate-checksum.processor'; | ||||||
| import { MachineLearningProcessor } from './processors/machine-learning.processor'; | import { MachineLearningProcessor } from './processors/machine-learning.processor'; | ||||||
| import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; | import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; | ||||||
|  | import { StorageMigrationProcessor } from './processors/storage-migration.processor'; | ||||||
| import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; | import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; | ||||||
| import { UserDeletionProcessor } from './processors/user-deletion.processor'; | import { UserDeletionProcessor } from './processors/user-deletion.processor'; | ||||||
| import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; | import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; | ||||||
|  | import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; | ||||||
|  |  | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [ |   imports: [ | ||||||
| @@ -26,76 +28,9 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' | |||||||
|     DatabaseModule, |     DatabaseModule, | ||||||
|     ImmichConfigModule, |     ImmichConfigModule, | ||||||
|     TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]), |     TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]), | ||||||
|     BullModule.forRootAsync({ |     StorageModule, | ||||||
|       useFactory: async () => ({ |     BullModule.forRootAsync(immichBullAsyncConfig), | ||||||
|         prefix: 'immich_bull', |     BullModule.registerQueue(...immichSharedQueues), | ||||||
|         redis: { |  | ||||||
|           host: process.env.REDIS_HOSTNAME || 'immich_redis', |  | ||||||
|           port: parseInt(process.env.REDIS_PORT || '6379'), |  | ||||||
|           db: parseInt(process.env.REDIS_DBINDEX || '0'), |  | ||||||
|           password: process.env.REDIS_PASSWORD || undefined, |  | ||||||
|           path: process.env.REDIS_SOCKET || undefined, |  | ||||||
|         }, |  | ||||||
|       }), |  | ||||||
|     }), |  | ||||||
|     BullModule.registerQueue( |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.USER_DELETION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.THUMBNAIL_GENERATION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.ASSET_UPLOADED, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.METADATA_EXTRACTION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.VIDEO_CONVERSION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.CHECKSUM_GENERATION, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |  | ||||||
|         name: QueueNameEnum.MACHINE_LEARNING, |  | ||||||
|         defaultJobOptions: { |  | ||||||
|           attempts: 3, |  | ||||||
|           removeOnComplete: true, |  | ||||||
|           removeOnFail: false, |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|     ), |  | ||||||
|     CommunicationModule, |     CommunicationModule, | ||||||
|   ], |   ], | ||||||
|   controllers: [], |   controllers: [], | ||||||
| @@ -108,7 +43,8 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' | |||||||
|     GenerateChecksumProcessor, |     GenerateChecksumProcessor, | ||||||
|     MachineLearningProcessor, |     MachineLearningProcessor, | ||||||
|     UserDeletionProcessor, |     UserDeletionProcessor, | ||||||
|  |     StorageMigrationProcessor, | ||||||
|   ], |   ], | ||||||
|   exports: [], |   exports: [BullModule], | ||||||
| }) | }) | ||||||
| export class MicroservicesModule {} | export class MicroservicesModule {} | ||||||
|   | |||||||
| @@ -0,0 +1,61 @@ | |||||||
|  | import { APP_UPLOAD_LOCATION } from '@app/common'; | ||||||
|  | import { AssetEntity } from '@app/database/entities/asset.entity'; | ||||||
|  | import { ImmichConfigService } from '@app/immich-config'; | ||||||
|  | import { QueueNameEnum, templateMigrationProcessorName, updateTemplateProcessorName } from '@app/job'; | ||||||
|  | import { StorageService } from '@app/storage'; | ||||||
|  | import { Process, Processor } from '@nestjs/bull'; | ||||||
|  | import { Logger } from '@nestjs/common'; | ||||||
|  | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
|  | import { Repository } from 'typeorm'; | ||||||
|  |  | ||||||
|  | @Processor(QueueNameEnum.STORAGE_MIGRATION) | ||||||
|  | export class StorageMigrationProcessor { | ||||||
|  |   readonly logger: Logger = new Logger(StorageMigrationProcessor.name); | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     private storageService: StorageService, | ||||||
|  |     private immichConfigService: ImmichConfigService, | ||||||
|  |  | ||||||
|  |     @InjectRepository(AssetEntity) | ||||||
|  |     private assetRepository: Repository<AssetEntity>, | ||||||
|  |   ) {} | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Migration process when a new user set a new storage template. | ||||||
|  |    * @param job | ||||||
|  |    */ | ||||||
|  |   @Process({ name: templateMigrationProcessorName, concurrency: 100 }) | ||||||
|  |   async templateMigration() { | ||||||
|  |     console.time('migrating-time'); | ||||||
|  |     const assets = await this.assetRepository.find({ | ||||||
|  |       relations: ['exifInfo'], | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     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]; | ||||||
|  |       const filename = asset.exifInfo?.imageName || livePhotoParentAsset?.exifInfo?.imageName || asset.id; | ||||||
|  |       await this.storageService.moveAsset(asset, filename); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await this.storageService.removeEmptyDirectories(APP_UPLOAD_LOCATION); | ||||||
|  |     console.timeEnd('migrating-time'); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Update config when a new storage template is set. | ||||||
|  |    * This is to ensure the synchronization between processes. | ||||||
|  |    * @param job | ||||||
|  |    */ | ||||||
|  |   @Process({ name: updateTemplateProcessorName, concurrency: 1 }) | ||||||
|  |   async updateTemplate() { | ||||||
|  |     await this.immichConfigService.refreshConfig(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,5 +1,4 @@ | |||||||
| import { APP_UPLOAD_LOCATION } from '@app/common'; | import { APP_UPLOAD_LOCATION } from '@app/common'; | ||||||
| import { ImmichLogLevel } from '@app/common/constants/log-level.constant'; |  | ||||||
| import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; | import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; | ||||||
| import { | import { | ||||||
|   WebpGeneratorProcessor, |   WebpGeneratorProcessor, | ||||||
| @@ -11,7 +10,6 @@ import { | |||||||
| } from '@app/job'; | } from '@app/job'; | ||||||
| import { InjectQueue, Process, Processor } from '@nestjs/bull'; | import { InjectQueue, Process, Processor } from '@nestjs/bull'; | ||||||
| import { Logger } from '@nestjs/common'; | import { Logger } from '@nestjs/common'; | ||||||
| import { ConfigService } from '@nestjs/config'; |  | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto'; | import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto'; | ||||||
| import { Job, Queue } from 'bull'; | import { Job, Queue } from 'bull'; | ||||||
| @@ -27,7 +25,7 @@ import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interf | |||||||
|  |  | ||||||
| @Processor(QueueNameEnum.THUMBNAIL_GENERATION) | @Processor(QueueNameEnum.THUMBNAIL_GENERATION) | ||||||
| export class ThumbnailGeneratorProcessor { | export class ThumbnailGeneratorProcessor { | ||||||
|   private logLevel: ImmichLogLevel; |   readonly logger: Logger = new Logger(ThumbnailGeneratorProcessor.name); | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     @InjectRepository(AssetEntity) |     @InjectRepository(AssetEntity) | ||||||
| @@ -40,12 +38,7 @@ export class ThumbnailGeneratorProcessor { | |||||||
|  |  | ||||||
|     @InjectQueue(QueueNameEnum.MACHINE_LEARNING) |     @InjectQueue(QueueNameEnum.MACHINE_LEARNING) | ||||||
|     private machineLearningQueue: Queue<IMachineLearningJob>, |     private machineLearningQueue: Queue<IMachineLearningJob>, | ||||||
|  |   ) {} | ||||||
|     private configService: ConfigService, |  | ||||||
|   ) { |  | ||||||
|     this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE; |  | ||||||
|     // TODO - Add observable paterrn to listen to the config change |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 }) |   @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 }) | ||||||
|   async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) { |   async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) { | ||||||
| @@ -70,12 +63,8 @@ export class ThumbnailGeneratorProcessor { | |||||||
|           .rotate() |           .rotate() | ||||||
|           .toFile(jpegThumbnailPath); |           .toFile(jpegThumbnailPath); | ||||||
|         await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); |         await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); | ||||||
|       } catch (error) { |       } catch (error: any) { | ||||||
|         Logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id); |         this.logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id, error.stack); | ||||||
|  |  | ||||||
|         if (this.logLevel == ImmichLogLevel.VERBOSE) { |  | ||||||
|           console.trace('Failed to generate jpeg thumbnail for asset', error); |  | ||||||
|         } |  | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Update resize path to send to generate webp queue |       // Update resize path to send to generate webp queue | ||||||
| @@ -140,12 +129,8 @@ export class ThumbnailGeneratorProcessor { | |||||||
|     try { |     try { | ||||||
|       await sharp(asset.resizePath, { failOnError: false }).resize(250).webp().rotate().toFile(webpPath); |       await sharp(asset.resizePath, { failOnError: false }).resize(250).webp().rotate().toFile(webpPath); | ||||||
|       await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); |       await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); | ||||||
|     } catch (error) { |     } catch (error: any) { | ||||||
|       Logger.error('Failed to generate webp thumbnail for asset: ' + asset.id); |       this.logger.error('Failed to generate webp thumbnail for asset: ' + asset.id, error.stack); | ||||||
|  |  | ||||||
|       if (this.logLevel == ImmichLogLevel.VERBOSE) { |  | ||||||
|         console.trace('Failed to generate webp thumbnail for asset', error); |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3562,6 +3562,9 @@ | |||||||
|           "machineLearningQueueCount": { |           "machineLearningQueueCount": { | ||||||
|             "$ref": "#/components/schemas/JobCounts" |             "$ref": "#/components/schemas/JobCounts" | ||||||
|           }, |           }, | ||||||
|  |           "storageMigrationQueueCount": { | ||||||
|  |             "$ref": "#/components/schemas/JobCounts" | ||||||
|  |           }, | ||||||
|           "isThumbnailGenerationActive": { |           "isThumbnailGenerationActive": { | ||||||
|             "type": "boolean" |             "type": "boolean" | ||||||
|           }, |           }, | ||||||
| @@ -3573,6 +3576,9 @@ | |||||||
|           }, |           }, | ||||||
|           "isMachineLearningActive": { |           "isMachineLearningActive": { | ||||||
|             "type": "boolean" |             "type": "boolean" | ||||||
|  |           }, | ||||||
|  |           "isStorageMigrationActive": { | ||||||
|  |             "type": "boolean" | ||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
|         "required": [ |         "required": [ | ||||||
| @@ -3580,10 +3586,12 @@ | |||||||
|           "metadataExtractionQueueCount", |           "metadataExtractionQueueCount", | ||||||
|           "videoConversionQueueCount", |           "videoConversionQueueCount", | ||||||
|           "machineLearningQueueCount", |           "machineLearningQueueCount", | ||||||
|  |           "storageMigrationQueueCount", | ||||||
|           "isThumbnailGenerationActive", |           "isThumbnailGenerationActive", | ||||||
|           "isMetadataExtractionActive", |           "isMetadataExtractionActive", | ||||||
|           "isVideoConversionActive", |           "isVideoConversionActive", | ||||||
|           "isMachineLearningActive" |           "isMachineLearningActive", | ||||||
|  |           "isStorageMigrationActive" | ||||||
|         ] |         ] | ||||||
|       }, |       }, | ||||||
|       "JobId": { |       "JobId": { | ||||||
| @@ -3592,7 +3600,8 @@ | |||||||
|           "thumbnail-generation", |           "thumbnail-generation", | ||||||
|           "metadata-extraction", |           "metadata-extraction", | ||||||
|           "video-conversion", |           "video-conversion", | ||||||
|           "machine-learning" |           "machine-learning", | ||||||
|  |           "storage-template-migration" | ||||||
|         ] |         ] | ||||||
|       }, |       }, | ||||||
|       "JobStatusResponseDto": { |       "JobStatusResponseDto": { | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								server/libs/common/src/config/bull-queue.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								server/libs/common/src/config/bull-queue.config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | import { SharedBullAsyncConfiguration } from '@nestjs/bull'; | ||||||
|  |  | ||||||
|  | export const immichBullAsyncConfig: SharedBullAsyncConfiguration = { | ||||||
|  |   useFactory: async () => ({ | ||||||
|  |     prefix: 'immich_bull', | ||||||
|  |     redis: { | ||||||
|  |       host: process.env.REDIS_HOSTNAME || 'immich_redis', | ||||||
|  |       port: parseInt(process.env.REDIS_PORT || '6379'), | ||||||
|  |       db: parseInt(process.env.REDIS_DBINDEX || '0'), | ||||||
|  |       password: process.env.REDIS_PASSWORD || undefined, | ||||||
|  |       path: process.env.REDIS_SOCKET || undefined, | ||||||
|  |     }, | ||||||
|  |     defaultJobOptions: { | ||||||
|  |       attempts: 3, | ||||||
|  |       removeOnComplete: true, | ||||||
|  |       removeOnFail: false, | ||||||
|  |     }, | ||||||
|  |   }), | ||||||
|  | }; | ||||||
| @@ -1 +1,2 @@ | |||||||
| export * from './app.config'; | export * from './app.config'; | ||||||
|  | export * from './bull-queue.config'; | ||||||
|   | |||||||
| @@ -102,4 +102,10 @@ export class ImmichConfigService { | |||||||
|  |  | ||||||
|     return newConfig; |     return newConfig; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public async refreshConfig() { | ||||||
|  |     const newConfig = await this.getConfig(); | ||||||
|  |  | ||||||
|  |     this.config$.next(newConfig); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,32 @@ | |||||||
|  | import { BullModuleOptions } from '@nestjs/bull'; | ||||||
|  | import { QueueNameEnum } from './queue-name.constant'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Shared queues between apps and microservices | ||||||
|  |  */ | ||||||
|  | export const immichSharedQueues: BullModuleOptions[] = [ | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.USER_DELETION, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.THUMBNAIL_GENERATION, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.ASSET_UPLOADED, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.METADATA_EXTRACTION, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.VIDEO_CONVERSION, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.CHECKSUM_GENERATION, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.MACHINE_LEARNING, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: QueueNameEnum.STORAGE_MIGRATION, | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
| @@ -34,3 +34,9 @@ export enum MachineLearningJobNameEnum { | |||||||
|  * User deletion Queue Jobs |  * User deletion Queue Jobs | ||||||
|  */ |  */ | ||||||
| export const userDeletionProcessorName = 'user-deletion'; | export const userDeletionProcessorName = 'user-deletion'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Storage Template Migration Queue Jobs | ||||||
|  |  */ | ||||||
|  | export const templateMigrationProcessorName = 'template-migration'; | ||||||
|  | export const updateTemplateProcessorName = 'update-template'; | ||||||
|   | |||||||
| @@ -6,4 +6,5 @@ export enum QueueNameEnum { | |||||||
|   ASSET_UPLOADED = 'asset-uploaded-queue', |   ASSET_UPLOADED = 'asset-uploaded-queue', | ||||||
|   MACHINE_LEARNING = 'machine-learning-queue', |   MACHINE_LEARNING = 'machine-learning-queue', | ||||||
|   USER_DELETION = 'user-deletion-queue', |   USER_DELETION = 'user-deletion-queue', | ||||||
|  |   STORAGE_MIGRATION = 'storage-template-migration', | ||||||
| } | } | ||||||
|   | |||||||
| @@ -26,7 +26,7 @@ const moveFile = promisify<string, string, mv.Options>(mv); | |||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| export class StorageService { | export class StorageService { | ||||||
|   readonly log = new Logger(StorageService.name); |   readonly logger = new Logger(StorageService.name); | ||||||
|  |  | ||||||
|   private storageTemplate: HandlebarsTemplateDelegate<any>; |   private storageTemplate: HandlebarsTemplateDelegate<any>; | ||||||
|  |  | ||||||
| @@ -41,7 +41,7 @@ export class StorageService { | |||||||
|     this.immichConfigService.addValidator((config) => this.validateConfig(config)); |     this.immichConfigService.addValidator((config) => this.validateConfig(config)); | ||||||
|  |  | ||||||
|     this.immichConfigService.config$.subscribe((config) => { |     this.immichConfigService.config$.subscribe((config) => { | ||||||
|       this.log.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); |       this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); | ||||||
|       this.storageTemplate = this.compile(config.storageTemplate.template); |       this.storageTemplate = this.compile(config.storageTemplate.template); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| @@ -54,14 +54,40 @@ export class StorageService { | |||||||
|       const rootPath = path.join(APP_UPLOAD_LOCATION, asset.userId); |       const rootPath = path.join(APP_UPLOAD_LOCATION, asset.userId); | ||||||
|       const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); |       const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); | ||||||
|       const fullPath = path.normalize(path.join(rootPath, storagePath)); |       const fullPath = path.normalize(path.join(rootPath, storagePath)); | ||||||
|  |       let destination = `${fullPath}.${ext}`; | ||||||
|  |  | ||||||
|       if (!fullPath.startsWith(rootPath)) { |       if (!fullPath.startsWith(rootPath)) { | ||||||
|         this.log.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`); |         this.logger.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`); | ||||||
|         return asset; |         return asset; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       if (source === destination) { | ||||||
|  |         return asset; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       /** | ||||||
|  |        * 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 asset; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|       let duplicateCount = 0; |       let duplicateCount = 0; | ||||||
|       let destination = `${fullPath}.${ext}`; |  | ||||||
|  |  | ||||||
|       while (true) { |       while (true) { | ||||||
|         const exists = await this.checkFileExist(destination); |         const exists = await this.checkFileExist(destination); | ||||||
| @@ -70,7 +96,7 @@ export class StorageService { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         duplicateCount++; |         duplicateCount++; | ||||||
|         destination = `${fullPath}_${duplicateCount}.${ext}`; |         destination = `${fullPath}+${duplicateCount}.${ext}`; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       await this.safeMove(source, destination); |       await this.safeMove(source, destination); | ||||||
| @@ -78,7 +104,7 @@ export class StorageService { | |||||||
|       asset.originalPath = destination; |       asset.originalPath = destination; | ||||||
|       return await this.assetRepository.save(asset); |       return await this.assetRepository.save(asset); | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       this.log.error(error, error.stack); |       this.logger.error(error); | ||||||
|       return asset; |       return asset; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -115,7 +141,7 @@ export class StorageService { | |||||||
|         'jpg', |         'jpg', | ||||||
|       ); |       ); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       this.log.warn(`Storage template validation failed: ${e}`); |       this.logger.warn(`Storage template validation failed: ${e}`); | ||||||
|       throw new Error(`Invalid storage template: ${e}`); |       throw new Error(`Invalid storage template: ${e}`); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -150,4 +176,27 @@ export class StorageService { | |||||||
|  |  | ||||||
|     return template(substitutions); |     return template(substitutions); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public async removeEmptyDirectories(directory: string) { | ||||||
|  |     // lstat does not follow symlinks (in contrast to stat) | ||||||
|  |     const fileStats = await fsPromise.lstat(directory); | ||||||
|  |     if (!fileStats.isDirectory()) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     let fileNames = await fsPromise.readdir(directory); | ||||||
|  |     if (fileNames.length > 0) { | ||||||
|  |       const recursiveRemovalPromises = fileNames.map((fileName) => | ||||||
|  |         this.removeEmptyDirectories(path.join(directory, fileName)), | ||||||
|  |       ); | ||||||
|  |       await Promise.all(recursiveRemovalPromises); | ||||||
|  |  | ||||||
|  |       // re-evaluate fileNames; after deleting subdirectory | ||||||
|  |       // we may have parent directory empty now | ||||||
|  |       fileNames = await fsPromise.readdir(directory); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (fileNames.length === 0) { | ||||||
|  |       await fsPromise.rmdir(directory); | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										15
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -225,6 +225,12 @@ export interface AllJobStatusResponseDto { | |||||||
|      * @memberof AllJobStatusResponseDto |      * @memberof AllJobStatusResponseDto | ||||||
|      */ |      */ | ||||||
|     'machineLearningQueueCount': JobCounts; |     'machineLearningQueueCount': JobCounts; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {JobCounts} | ||||||
|  |      * @memberof AllJobStatusResponseDto | ||||||
|  |      */ | ||||||
|  |     'storageMigrationQueueCount': JobCounts; | ||||||
|     /** |     /** | ||||||
|      *  |      *  | ||||||
|      * @type {boolean} |      * @type {boolean} | ||||||
| @@ -249,6 +255,12 @@ export interface AllJobStatusResponseDto { | |||||||
|      * @memberof AllJobStatusResponseDto |      * @memberof AllJobStatusResponseDto | ||||||
|      */ |      */ | ||||||
|     'isMachineLearningActive': boolean; |     'isMachineLearningActive': boolean; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {boolean} | ||||||
|  |      * @memberof AllJobStatusResponseDto | ||||||
|  |      */ | ||||||
|  |     'isStorageMigrationActive': boolean; | ||||||
| } | } | ||||||
| /** | /** | ||||||
|  *  |  *  | ||||||
| @@ -1038,7 +1050,8 @@ export const JobId = { | |||||||
|     ThumbnailGeneration: 'thumbnail-generation', |     ThumbnailGeneration: 'thumbnail-generation', | ||||||
|     MetadataExtraction: 'metadata-extraction', |     MetadataExtraction: 'metadata-extraction', | ||||||
|     VideoConversion: 'video-conversion', |     VideoConversion: 'video-conversion', | ||||||
|     MachineLearning: 'machine-learning' |     MachineLearning: 'machine-learning', | ||||||
|  |     StorageTemplateMigration: 'storage-template-migration' | ||||||
| } as const; | } as const; | ||||||
| 
 | 
 | ||||||
| export type JobId = typeof JobId[keyof typeof JobId]; | export type JobId = typeof JobId[keyof typeof JobId]; | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ | |||||||
|  |  | ||||||
| 	let allJobsStatus: AllJobStatusResponseDto; | 	let allJobsStatus: AllJobStatusResponseDto; | ||||||
| 	let setIntervalHandler: NodeJS.Timer; | 	let setIntervalHandler: NodeJS.Timer; | ||||||
|  |  | ||||||
| 	onMount(async () => { | 	onMount(async () => { | ||||||
| 		const { data } = await api.jobApi.getAllJobsStatus(); | 		const { data } = await api.jobApi.getAllJobsStatus(); | ||||||
| 		allJobsStatus = data; | 		allJobsStatus = data; | ||||||
| @@ -104,6 +105,33 @@ | |||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	const runTemplateMigration = async () => { | ||||||
|  | 		try { | ||||||
|  | 			const { data } = await api.jobApi.sendJobCommand(JobId.StorageTemplateMigration, { | ||||||
|  | 				command: JobCommand.Start | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			if (data) { | ||||||
|  | 				notificationController.show({ | ||||||
|  | 					message: `Storage migration started`, | ||||||
|  | 					type: NotificationType.Info | ||||||
|  | 				}); | ||||||
|  | 			} else { | ||||||
|  | 				notificationController.show({ | ||||||
|  | 					message: `All files have been migrated to the new storage template`, | ||||||
|  | 					type: NotificationType.Info | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 		} catch (e) { | ||||||
|  | 			console.log('[ERROR] runTemplateMigration', e); | ||||||
|  |  | ||||||
|  | 			notificationController.show({ | ||||||
|  | 				message: `Error running template migration job, check console for more detail`, | ||||||
|  | 				type: NotificationType.Error | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <div class="flex flex-col gap-10"> | <div class="flex flex-col gap-10"> | ||||||
| @@ -135,4 +163,20 @@ | |||||||
| 	> | 	> | ||||||
| 		Note that some asset does not have any object detected, this is normal. | 		Note that some asset does not have any object detected, this is normal. | ||||||
| 	</JobTile> | 	</JobTile> | ||||||
|  |  | ||||||
|  | 	<JobTile | ||||||
|  | 		title={'Storage migration'} | ||||||
|  | 		subtitle={''} | ||||||
|  | 		on:click={runTemplateMigration} | ||||||
|  | 		jobStatus={allJobsStatus?.isStorageMigrationActive} | ||||||
|  | 		waitingJobCount={allJobsStatus?.storageMigrationQueueCount.waiting} | ||||||
|  | 		activeJobCount={allJobsStatus?.storageMigrationQueueCount.active} | ||||||
|  | 	> | ||||||
|  | 		Apply the current | ||||||
|  | 		<a | ||||||
|  | 			href="/admin/system-settings?open=storage-template" | ||||||
|  | 			class="text-immich-primary dark:text-immich-dark-primary">Storage template</a | ||||||
|  | 		> | ||||||
|  | 		to previously uploaded assets | ||||||
|  | 	</JobTile> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
| 	export let title: string; | 	export let title: string; | ||||||
| 	export let subtitle = ''; | 	export let subtitle = ''; | ||||||
|  |  | ||||||
| 	let isOpen = false; | 	export let isOpen = false; | ||||||
| 	const toggle = () => (isOpen = !isOpen); | 	const toggle = () => (isOpen = !isOpen); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -214,6 +214,16 @@ | |||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</div> | ||||||
|  |  | ||||||
|  | 					<div id="migration-info" class="text-sm mt-4"> | ||||||
|  | 						<p> | ||||||
|  | 							Template changes will only apply to new assets. To retroactively apply the template to | ||||||
|  | 							previously uploaded assets, run the <a | ||||||
|  | 								href="/admin/jobs-status" | ||||||
|  | 								class="text-immich-primary dark:text-immich-dark-primary">Storage Migration Job</a | ||||||
|  | 							> | ||||||
|  | 						</p> | ||||||
|  | 					</div> | ||||||
|  |  | ||||||
| 					<SettingButtonsRow | 					<SettingButtonsRow | ||||||
| 						on:reset={reset} | 						on:reset={reset} | ||||||
| 						on:save={saveSetting} | 						on:save={saveSetting} | ||||||
|   | |||||||
| @@ -73,7 +73,7 @@ | |||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			<section id="setting-content" class="pt-[85px] flex place-content-center"> | 			<section id="setting-content" class="pt-[85px] flex place-content-center"> | ||||||
| 				<section class="w-[800px] pt-5"> | 				<section class="w-[800px] pt-5 pb-28"> | ||||||
| 					<slot /> | 					<slot /> | ||||||
| 				</section> | 				</section> | ||||||
| 			</section> | 			</section> | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ | |||||||
| 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||||||
| 	import { api, SystemConfigDto } from '@api'; | 	import { api, SystemConfigDto } from '@api'; | ||||||
| 	import type { PageData } from './$types'; | 	import type { PageData } from './$types'; | ||||||
|  | 	import { page } from '$app/stores'; | ||||||
|  |  | ||||||
| 	let systemConfig: SystemConfigDto; | 	let systemConfig: SystemConfigDto; | ||||||
| 	export let data: PageData; | 	export let data: PageData; | ||||||
| @@ -39,6 +40,7 @@ | |||||||
| 		<SettingAccordion | 		<SettingAccordion | ||||||
| 			title="Storage Template" | 			title="Storage Template" | ||||||
| 			subtitle="Manage the folder structure and file name of the upload asset" | 			subtitle="Manage the folder structure and file name of the upload asset" | ||||||
|  | 			isOpen={$page.url.searchParams.get('open') === 'storage-template'} | ||||||
| 		> | 		> | ||||||
| 			<StorageTemplateSettings storageConfig={configs.storageTemplate} user={data.user} /> | 			<StorageTemplateSettings storageConfig={configs.storageTemplate} user={data.user} /> | ||||||
| 		</SettingAccordion> | 		</SettingAccordion> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user