Browse Source

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>
Markus 1 year ago
parent
commit
dd52ff2d33

+ 14 - 1
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<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', () => {

+ 55 - 22
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<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;

+ 1 - 0
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';

+ 1 - 0
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'],

+ 21 - 7
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 @@
             </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

+ 6 - 12
web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte

@@ -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>