1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +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:
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 {
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', () => {

View file

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

View file

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

View file

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

View file

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

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="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>