diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 8f6833699d..825a5cb25e 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -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; let assetMock: jest.Mocked; let configMock: jest.Mocked; let moveMock: jest.Mocked; @@ -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', () => { diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index bf0c5d8f78..b14d21bcf9 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -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; + private template: { + compiled: HandlebarsTemplateDelegate; + 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, asset: AssetEntity, filename: string, ext: string) { + private render(template: HandlebarsTemplateDelegate, options: RenderMetadata) { + const { filename, extension, asset, albumName } = options; const substitutions: Record = { 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; diff --git a/server/src/domain/system-config/system-config.constants.ts b/server/src/domain/system-config/system-config.constants.ts index a1bb03a4c8..65808f6df2 100644 --- a/server/src/domain/system-config/system-config.constants.ts +++ b/server/src/domain/system-config/system-config.constants.ts @@ -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'; diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index b094b328ed..0444217c09 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -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'], diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index e744b182d7..fc95e6d7b2 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -57,6 +57,7 @@ filetype: 'IMG', filetypefull: 'IMAGE', assetId: 'a8312960-e277-447d-b4ea-56717ccba856', + album: 'Album Name', }; const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString()); @@ -208,13 +209,26 @@ -
-

- Template changes will only apply to new assets. To retroactively apply the template to previously uploaded - assets, run the Storage Migration Job -

+
+

Notes

+
+

+ Template changes will only apply to new assets. To retroactively apply the template to previously + uploaded assets, run the + Storage Migration Job. +

+

+ The template variable {`{{album}}`} will always be empty for new assets, + so manually running the + + Storage Migration Job + is required in order to successfully use the variable. +

+
-

FILE NAME

+

FILENAME

    -
  • {`{{filename}}`}
  • +
  • {`{{filename}}`} - IMG_123
  • +
  • {`{{ext}}`} - jpg
-

FILE EXTENSION

-
    -
  • {`{{ext}}`}
  • -
-
- -
-

FILE TYPE

+

FILETYPE

  • {`{{filetype}}`} - VID or IMG
  • {`{{filetypefull}}`} - VIDEO or IMAGE
-
-

FILE TYPE

+

OTHER

  • {`{{assetId}}`} - Asset ID
  • +
  • {`{{album}}`} - Album Name