1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-06 03:46:47 +01: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:
Markus 2023-10-23 20:00:31 +02:00 committed by GitHub
parent 093347c7ab
commit dd52ff2d33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 98 additions and 42 deletions

View file

@ -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', () => {

View file

@ -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 = { try {
const { compiled } = this.compile(config.storageTemplate.template);
this.render(compiled, {
asset: {
fileCreatedAt: new Date(), fileCreatedAt: new Date(),
originalPath: '/upload/test/IMG_123.jpg', originalPath: '/upload/test/IMG_123.jpg',
type: AssetType.IMAGE, type: AssetType.IMAGE,
id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e',
} as AssetEntity; } as AssetEntity,
try { filename: 'IMG_123',
this.render(this.compile(config.storageTemplate.template), testAsset, 'IMG_123', 'jpg'); 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;

View file

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

View file

@ -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'],

View file

@ -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">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
<section class="flex flex-col gap-2">
<p> <p>
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded Template changes will only apply to new assets. To retroactively apply the template to previously
assets, run the <a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary" 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 >Storage Migration Job</a
> >
is required in order to successfully use the variable.
</p> </p>
</section>
</div> </div>
<SettingButtonsRow <SettingButtonsRow

View file

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