mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:49:11 -04:00 
			
		
		
		
	feat(server): "{album}" in storage template (#2973)
* feat(server): add to storage template * feat: add album preset --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		
							parent
							
								
									093347c7ab
								
							
						
					
					
						commit
						dd52ff2d33
					
				| @ -1,6 +1,7 @@ | |||||||
| import { AssetPathType } from '@app/infra/entities'; | import { AssetPathType } from '@app/infra/entities'; | ||||||
| import { | import { | ||||||
|   assetStub, |   assetStub, | ||||||
|  |   newAlbumRepositoryMock, | ||||||
|   newAssetRepositoryMock, |   newAssetRepositoryMock, | ||||||
|   newMoveRepositoryMock, |   newMoveRepositoryMock, | ||||||
|   newPersonRepositoryMock, |   newPersonRepositoryMock, | ||||||
| @ -11,6 +12,7 @@ import { | |||||||
| } from '@test'; | } from '@test'; | ||||||
| import { when } from 'jest-when'; | import { when } from 'jest-when'; | ||||||
| import { | import { | ||||||
|  |   IAlbumRepository, | ||||||
|   IAssetRepository, |   IAssetRepository, | ||||||
|   IMoveRepository, |   IMoveRepository, | ||||||
|   IPersonRepository, |   IPersonRepository, | ||||||
| @ -23,6 +25,7 @@ import { StorageTemplateService } from './storage-template.service'; | |||||||
| 
 | 
 | ||||||
| describe(StorageTemplateService.name, () => { | describe(StorageTemplateService.name, () => { | ||||||
|   let sut: StorageTemplateService; |   let sut: StorageTemplateService; | ||||||
|  |   let albumMock: jest.Mocked<IAlbumRepository>; | ||||||
|   let assetMock: jest.Mocked<IAssetRepository>; |   let assetMock: jest.Mocked<IAssetRepository>; | ||||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; |   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||||
|   let moveMock: jest.Mocked<IMoveRepository>; |   let moveMock: jest.Mocked<IMoveRepository>; | ||||||
| @ -36,13 +39,23 @@ describe(StorageTemplateService.name, () => { | |||||||
| 
 | 
 | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     assetMock = newAssetRepositoryMock(); |     assetMock = newAssetRepositoryMock(); | ||||||
|  |     albumMock = newAlbumRepositoryMock(); | ||||||
|     configMock = newSystemConfigRepositoryMock(); |     configMock = newSystemConfigRepositoryMock(); | ||||||
|     moveMock = newMoveRepositoryMock(); |     moveMock = newMoveRepositoryMock(); | ||||||
|     personMock = newPersonRepositoryMock(); |     personMock = newPersonRepositoryMock(); | ||||||
|     storageMock = newStorageRepositoryMock(); |     storageMock = newStorageRepositoryMock(); | ||||||
|     userMock = newUserRepositoryMock(); |     userMock = newUserRepositoryMock(); | ||||||
| 
 | 
 | ||||||
|     sut = new StorageTemplateService(assetMock, configMock, defaults, moveMock, personMock, storageMock, userMock); |     sut = new StorageTemplateService( | ||||||
|  |       albumMock, | ||||||
|  |       assetMock, | ||||||
|  |       configMock, | ||||||
|  |       defaults, | ||||||
|  |       moveMock, | ||||||
|  |       personMock, | ||||||
|  |       storageMock, | ||||||
|  |       userMock, | ||||||
|  |     ); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('handleMigrationSingle', () => { |   describe('handleMigrationSingle', () => { | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ import sanitize from 'sanitize-filename'; | |||||||
| import { getLivePhotoMotionFilename, usePagination } from '../domain.util'; | import { getLivePhotoMotionFilename, usePagination } from '../domain.util'; | ||||||
| import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job'; | import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job'; | ||||||
| import { | import { | ||||||
|  |   IAlbumRepository, | ||||||
|   IAssetRepository, |   IAssetRepository, | ||||||
|   IMoveRepository, |   IMoveRepository, | ||||||
|   IPersonRepository, |   IPersonRepository, | ||||||
| @ -32,14 +33,26 @@ export interface MoveAssetMetadata { | |||||||
|   filename: string; |   filename: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | interface RenderMetadata { | ||||||
|  |   asset: AssetEntity; | ||||||
|  |   filename: string; | ||||||
|  |   extension: string; | ||||||
|  |   albumName: string | null; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @Injectable() | @Injectable() | ||||||
| export class StorageTemplateService { | export class StorageTemplateService { | ||||||
|   private logger = new Logger(StorageTemplateService.name); |   private logger = new Logger(StorageTemplateService.name); | ||||||
|   private configCore: SystemConfigCore; |   private configCore: SystemConfigCore; | ||||||
|   private storageCore: StorageCore; |   private storageCore: StorageCore; | ||||||
|   private storageTemplate: HandlebarsTemplateDelegate<any>; |   private template: { | ||||||
|  |     compiled: HandlebarsTemplateDelegate<any>; | ||||||
|  |     raw: string; | ||||||
|  |     needsAlbum: boolean; | ||||||
|  |   }; | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|  |     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, | ||||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, |     @Inject(IAssetRepository) private assetRepository: IAssetRepository, | ||||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, |     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||||
|     @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, |     @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, | ||||||
| @ -48,10 +61,14 @@ export class StorageTemplateService { | |||||||
|     @Inject(IStorageRepository) private storageRepository: IStorageRepository, |     @Inject(IStorageRepository) private storageRepository: IStorageRepository, | ||||||
|     @Inject(IUserRepository) private userRepository: IUserRepository, |     @Inject(IUserRepository) private userRepository: IUserRepository, | ||||||
|   ) { |   ) { | ||||||
|     this.storageTemplate = this.compile(config.storageTemplate.template); |     this.template = this.compile(config.storageTemplate.template); | ||||||
|     this.configCore = SystemConfigCore.create(configRepository); |     this.configCore = SystemConfigCore.create(configRepository); | ||||||
|     this.configCore.addValidator((config) => this.validate(config)); |     this.configCore.addValidator((config) => this.validate(config)); | ||||||
|     this.configCore.config$.subscribe((config) => this.onConfig(config)); |     this.configCore.config$.subscribe((config) => { | ||||||
|  |       const template = config.storageTemplate.template; | ||||||
|  |       this.logger.debug(`Received config, compiling storage template: ${template}`); | ||||||
|  |       this.template = this.compile(template); | ||||||
|  |     }); | ||||||
|     this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); |     this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -132,7 +149,19 @@ export class StorageTemplateService { | |||||||
|       const ext = path.extname(source).split('.').pop() as string; |       const ext = path.extname(source).split('.').pop() as string; | ||||||
|       const sanitized = sanitize(path.basename(filename, `.${ext}`)); |       const sanitized = sanitize(path.basename(filename, `.${ext}`)); | ||||||
|       const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); |       const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); | ||||||
|       const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); | 
 | ||||||
|  |       let albumName = null; | ||||||
|  |       if (this.template.needsAlbum) { | ||||||
|  |         const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id); | ||||||
|  |         albumName = albums?.[0]?.albumName || null; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       const storagePath = this.render(this.template.compiled, { | ||||||
|  |         asset, | ||||||
|  |         filename: sanitized, | ||||||
|  |         extension: ext, | ||||||
|  |         albumName, | ||||||
|  |       }); | ||||||
|       const fullPath = path.normalize(path.join(rootPath, storagePath)); |       const fullPath = path.normalize(path.join(rootPath, storagePath)); | ||||||
|       let destination = `${fullPath}.${ext}`; |       let destination = `${fullPath}.${ext}`; | ||||||
| 
 | 
 | ||||||
| @ -187,39 +216,43 @@ export class StorageTemplateService { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private validate(config: SystemConfig) { |   private validate(config: SystemConfig) { | ||||||
|     const testAsset = { |  | ||||||
|       fileCreatedAt: new Date(), |  | ||||||
|       originalPath: '/upload/test/IMG_123.jpg', |  | ||||||
|       type: AssetType.IMAGE, |  | ||||||
|       id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', |  | ||||||
|     } as AssetEntity; |  | ||||||
|     try { |     try { | ||||||
|       this.render(this.compile(config.storageTemplate.template), testAsset, 'IMG_123', 'jpg'); |       const { compiled } = this.compile(config.storageTemplate.template); | ||||||
|  |       this.render(compiled, { | ||||||
|  |         asset: { | ||||||
|  |           fileCreatedAt: new Date(), | ||||||
|  |           originalPath: '/upload/test/IMG_123.jpg', | ||||||
|  |           type: AssetType.IMAGE, | ||||||
|  |           id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', | ||||||
|  |         } as AssetEntity, | ||||||
|  |         filename: 'IMG_123', | ||||||
|  |         extension: 'jpg', | ||||||
|  |         albumName: 'album', | ||||||
|  |       }); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`); |       this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`); | ||||||
|       throw new Error(`Invalid storage template: ${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) { |   private compile(template: string) { | ||||||
|     return handlebar.compile(template, { |     return { | ||||||
|       knownHelpers: undefined, |       raw: template, | ||||||
|       strict: true, |       compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }), | ||||||
|     }); |       needsAlbum: template.indexOf('{{album}}') !== -1, | ||||||
|  |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) { |   private render(template: HandlebarsTemplateDelegate<any>, options: RenderMetadata) { | ||||||
|  |     const { filename, extension, asset, albumName } = options; | ||||||
|     const substitutions: Record<string, string> = { |     const substitutions: Record<string, string> = { | ||||||
|       filename, |       filename, | ||||||
|       ext, |       ext: extension, | ||||||
|       filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', |       filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', | ||||||
|       filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', |       filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', | ||||||
|       assetId: asset.id, |       assetId: asset.id, | ||||||
|  |       //just throw into the root if it doesn't belong to an album
 | ||||||
|  |       album: (albumName && sanitize(albumName.replace(/\.+/g, ''))) || '.', | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; |     const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ export const supportedPresetTokens = [ | |||||||
|   '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', |   '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', | ||||||
|   '{{y}}/{{y}}-{{MM}}/{{assetId}}', |   '{{y}}/{{y}}-{{MM}}/{{assetId}}', | ||||||
|   '{{y}}/{{y}}-{{WW}}/{{assetId}}', |   '{{y}}/{{y}}-{{WW}}/{{assetId}}', | ||||||
|  |   '{{album}}/{{filename}}', | ||||||
| ]; | ]; | ||||||
| 
 | 
 | ||||||
| export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; | export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; | ||||||
|  | |||||||
| @ -242,6 +242,7 @@ describe(SystemConfigService.name, () => { | |||||||
|           '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', |           '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', | ||||||
|           '{{y}}/{{y}}-{{MM}}/{{assetId}}', |           '{{y}}/{{y}}-{{MM}}/{{assetId}}', | ||||||
|           '{{y}}/{{y}}-{{WW}}/{{assetId}}', |           '{{y}}/{{y}}-{{WW}}/{{assetId}}', | ||||||
|  |           '{{album}}/{{filename}}', | ||||||
|         ], |         ], | ||||||
|         secondOptions: ['s', 'ss'], |         secondOptions: ['s', 'ss'], | ||||||
|         weekOptions: ['W', 'WW'], |         weekOptions: ['W', 'WW'], | ||||||
|  | |||||||
| @ -57,6 +57,7 @@ | |||||||
|       filetype: 'IMG', |       filetype: 'IMG', | ||||||
|       filetypefull: 'IMAGE', |       filetypefull: 'IMAGE', | ||||||
|       assetId: 'a8312960-e277-447d-b4ea-56717ccba856', |       assetId: 'a8312960-e277-447d-b4ea-56717ccba856', | ||||||
|  |       album: 'Album Name', | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString()); |     const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString()); | ||||||
| @ -208,13 +209,26 @@ | |||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           <div id="migration-info" class="mt-4 text-sm"> |           <div id="migration-info" class="mt-2 text-sm"> | ||||||
|             <p> |             <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3> | ||||||
|               Template changes will only apply to new assets. To retroactively apply the template to previously uploaded |             <section class="flex flex-col gap-2"> | ||||||
|               assets, run the <a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary" |               <p> | ||||||
|                 >Storage Migration Job</a |                 Template changes will only apply to new assets. To retroactively apply the template to previously | ||||||
|               > |                 uploaded assets, run the | ||||||
|             </p> |                 <a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary" | ||||||
|  |                   >Storage Migration Job</a | ||||||
|  |                 >. | ||||||
|  |               </p> | ||||||
|  |               <p> | ||||||
|  |                 The template variable <span class="font-mono">{`{{album}}`}</span> will always be empty for new assets, | ||||||
|  |                 so manually running the | ||||||
|  | 
 | ||||||
|  |                 <a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary" | ||||||
|  |                   >Storage Migration Job</a | ||||||
|  |                 > | ||||||
|  |                 is required in order to successfully use the variable. | ||||||
|  |               </p> | ||||||
|  |             </section> | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           <SettingButtonsRow |           <SettingButtonsRow | ||||||
|  | |||||||
| @ -5,31 +5,25 @@ | |||||||
| <div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg"> | <div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg"> | ||||||
|   <div class="flex gap-[50px]"> |   <div class="flex gap-[50px]"> | ||||||
|     <div> |     <div> | ||||||
|       <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE NAME</p> |       <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILENAME</p> | ||||||
|       <ul> |       <ul> | ||||||
|         <li>{`{{filename}}`}</li> |         <li>{`{{filename}}`} - IMG_123</li> | ||||||
|  |         <li>{`{{ext}}`} - jpg</li> | ||||||
|       </ul> |       </ul> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <div> |     <div> | ||||||
|       <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE EXTENSION</p> |       <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILETYPE</p> | ||||||
|       <ul> |  | ||||||
|         <li>{`{{ext}}`}</li> |  | ||||||
|       </ul> |  | ||||||
|     </div> |  | ||||||
| 
 |  | ||||||
|     <div> |  | ||||||
|       <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE TYPE</p> |  | ||||||
|       <ul> |       <ul> | ||||||
|         <li>{`{{filetype}}`} - VID or IMG</li> |         <li>{`{{filetype}}`} - VID or IMG</li> | ||||||
|         <li>{`{{filetypefull}}`} - VIDEO or IMAGE</li> |         <li>{`{{filetypefull}}`} - VIDEO or IMAGE</li> | ||||||
|       </ul> |       </ul> | ||||||
|     </div> |     </div> | ||||||
| 
 |  | ||||||
|     <div> |     <div> | ||||||
|       <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE TYPE</p> |       <p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">OTHER</p> | ||||||
|       <ul> |       <ul> | ||||||
|         <li>{`{{assetId}}`} - Asset ID</li> |         <li>{`{{assetId}}`} - Asset ID</li> | ||||||
|  |         <li>{`{{album}}`} - Album Name</li> | ||||||
|       </ul> |       </ul> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user