forked from Cutlery/immich
		
	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 {
 | 
			
		||||
  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'],
 | 
			
		||||
 | 
			
		||||
@ -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 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div id="migration-info" class="mt-4 text-sm">
 | 
			
		||||
            <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 id="migration-info" class="mt-2 text-sm">
 | 
			
		||||
            <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
 | 
			
		||||
            <section class="flex flex-col gap-2">
 | 
			
		||||
              <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>
 | 
			
		||||
              <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>
 | 
			
		||||
 | 
			
		||||
          <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="flex gap-[50px]">
 | 
			
		||||
    <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>
 | 
			
		||||
        <li>{`{{filename}}`}</li>
 | 
			
		||||
        <li>{`{{filename}}`} - IMG_123</li>
 | 
			
		||||
        <li>{`{{ext}}`} - jpg</li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div>
 | 
			
		||||
      <p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE EXTENSION</p>
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>{`{{ext}}`}</li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </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">FILETYPE</p>
 | 
			
		||||
      <ul>
 | 
			
		||||
        <li>{`{{filetype}}`} - VID or IMG</li>
 | 
			
		||||
        <li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </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>
 | 
			
		||||
        <li>{`{{assetId}}`} - Asset ID</li>
 | 
			
		||||
        <li>{`{{album}}`} - Album Name</li>
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user