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:
parent
093347c7ab
commit
dd52ff2d33
6 changed files with 98 additions and 42 deletions
|
@ -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…
Reference in a new issue