mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00: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:
		| @@ -1,6 +1,7 @@ | ||||
| import { AssetPathType } from '@app/infra/entities'; | ||||
| import { | ||||
|   assetStub, | ||||
|   newAlbumRepositoryMock, | ||||
|   newAssetRepositoryMock, | ||||
|   newMoveRepositoryMock, | ||||
|   newPersonRepositoryMock, | ||||
| @@ -11,6 +12,7 @@ import { | ||||
| } from '@test'; | ||||
| import { when } from 'jest-when'; | ||||
| import { | ||||
|   IAlbumRepository, | ||||
|   IAssetRepository, | ||||
|   IMoveRepository, | ||||
|   IPersonRepository, | ||||
| @@ -23,6 +25,7 @@ import { StorageTemplateService } from './storage-template.service'; | ||||
|  | ||||
| describe(StorageTemplateService.name, () => { | ||||
|   let sut: StorageTemplateService; | ||||
|   let albumMock: jest.Mocked<IAlbumRepository>; | ||||
|   let assetMock: jest.Mocked<IAssetRepository>; | ||||
|   let configMock: jest.Mocked<ISystemConfigRepository>; | ||||
|   let moveMock: jest.Mocked<IMoveRepository>; | ||||
| @@ -36,13 +39,23 @@ describe(StorageTemplateService.name, () => { | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     assetMock = newAssetRepositoryMock(); | ||||
|     albumMock = newAlbumRepositoryMock(); | ||||
|     configMock = newSystemConfigRepositoryMock(); | ||||
|     moveMock = newMoveRepositoryMock(); | ||||
|     personMock = newPersonRepositoryMock(); | ||||
|     storageMock = newStorageRepositoryMock(); | ||||
|     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', () => { | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename'; | ||||
| import { getLivePhotoMotionFilename, usePagination } from '../domain.util'; | ||||
| import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job'; | ||||
| import { | ||||
|   IAlbumRepository, | ||||
|   IAssetRepository, | ||||
|   IMoveRepository, | ||||
|   IPersonRepository, | ||||
| @@ -32,14 +33,26 @@ export interface MoveAssetMetadata { | ||||
|   filename: string; | ||||
| } | ||||
|  | ||||
| interface RenderMetadata { | ||||
|   asset: AssetEntity; | ||||
|   filename: string; | ||||
|   extension: string; | ||||
|   albumName: string | null; | ||||
| } | ||||
|  | ||||
| @Injectable() | ||||
| export class StorageTemplateService { | ||||
|   private logger = new Logger(StorageTemplateService.name); | ||||
|   private configCore: SystemConfigCore; | ||||
|   private storageCore: StorageCore; | ||||
|   private storageTemplate: HandlebarsTemplateDelegate<any>; | ||||
|   private template: { | ||||
|     compiled: HandlebarsTemplateDelegate<any>; | ||||
|     raw: string; | ||||
|     needsAlbum: boolean; | ||||
|   }; | ||||
|  | ||||
|   constructor( | ||||
|     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, | ||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, | ||||
|     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, | ||||
|     @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, | ||||
| @@ -48,10 +61,14 @@ export class StorageTemplateService { | ||||
|     @Inject(IStorageRepository) private storageRepository: IStorageRepository, | ||||
|     @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.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); | ||||
|   } | ||||
|  | ||||
| @@ -132,7 +149,19 @@ export class StorageTemplateService { | ||||
|       const ext = path.extname(source).split('.').pop() as string; | ||||
|       const sanitized = sanitize(path.basename(filename, `.${ext}`)); | ||||
|       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)); | ||||
|       let destination = `${fullPath}.${ext}`; | ||||
|  | ||||
| @@ -187,39 +216,43 @@ export class StorageTemplateService { | ||||
|   } | ||||
|  | ||||
|   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 { | ||||
|       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) { | ||||
|       this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`); | ||||
|       throw new Error(`Invalid storage template: ${e}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private onConfig(config: SystemConfig) { | ||||
|     this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); | ||||
|     this.storageTemplate = this.compile(config.storageTemplate.template); | ||||
|   } | ||||
|  | ||||
|   private compile(template: string) { | ||||
|     return handlebar.compile(template, { | ||||
|       knownHelpers: undefined, | ||||
|       strict: true, | ||||
|     }); | ||||
|     return { | ||||
|       raw: template, | ||||
|       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> = { | ||||
|       filename, | ||||
|       ext, | ||||
|       ext: extension, | ||||
|       filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', | ||||
|       filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', | ||||
|       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; | ||||
|   | ||||
| @@ -23,6 +23,7 @@ export const supportedPresetTokens = [ | ||||
|   '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', | ||||
|   '{{y}}/{{y}}-{{MM}}/{{assetId}}', | ||||
|   '{{y}}/{{y}}-{{WW}}/{{assetId}}', | ||||
|   '{{album}}/{{filename}}', | ||||
| ]; | ||||
|  | ||||
| export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; | ||||
|   | ||||
| @@ -242,6 +242,7 @@ describe(SystemConfigService.name, () => { | ||||
|           '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', | ||||
|           '{{y}}/{{y}}-{{MM}}/{{assetId}}', | ||||
|           '{{y}}/{{y}}-{{WW}}/{{assetId}}', | ||||
|           '{{album}}/{{filename}}', | ||||
|         ], | ||||
|         secondOptions: ['s', 'ss'], | ||||
|         weekOptions: ['W', 'WW'], | ||||
|   | ||||
		Reference in New Issue
	
	Block a user