mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02: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:
parent
093347c7ab
commit
dd52ff2d33
6 changed files with 98 additions and 42 deletions
|
@ -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', () => {
|
||||||
|
|
|
@ -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 = {
|
|
||||||
fileCreatedAt: new Date(),
|
|
||||||
originalPath: '/upload/test/IMG_123.jpg',
|
|
||||||
type: AssetType.IMAGE,
|
|
||||||
id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e',
|
|
||||||
} as AssetEntity;
|
|
||||||
try {
|
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) {
|
} 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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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">
|
||||||
<p>
|
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
|
||||||
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded
|
<section class="flex flex-col gap-2">
|
||||||
assets, run the <a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
|
<p>
|
||||||
>Storage Migration Job</a
|
Template changes will only apply to new assets. To retroactively apply the template to previously
|
||||||
>
|
uploaded assets, run the
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<SettingButtonsRow
|
<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="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>
|
||||||
|
|
Loading…
Reference in a new issue