mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 12:15:47 -04:00
feat: add album start and end dates for storage template (#17188)
This commit is contained in:
parent
c70140e707
commit
fe8c5e8107
@ -69,6 +69,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||||
'{{y}}/{{MM}}/{{filename}}',
|
'{{y}}/{{MM}}/{{filename}}',
|
||||||
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
|
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
|
||||||
|
'{{#if album}}{{album-startDate-y}}/{{album}}{{else}}{{y}}/Other/{{MM}}{{/if}}/{{filename}}',
|
||||||
'{{y}}/{{MMM}}/{{filename}}',
|
'{{y}}/{{MMM}}/{{filename}}',
|
||||||
'{{y}}/{{MMMM}}/{{filename}}',
|
'{{y}}/{{MMMM}}/{{filename}}',
|
||||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||||
@ -182,6 +183,63 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle album startDate', async () => {
|
||||||
|
const asset = assetStub.storageAsset();
|
||||||
|
const user = userStub.user1;
|
||||||
|
const album = albumStub.oneAsset;
|
||||||
|
const config = structuredClone(defaults);
|
||||||
|
config.storageTemplate.template =
|
||||||
|
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
|
||||||
|
|
||||||
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(asset);
|
||||||
|
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||||
|
mocks.album.getMetadataForIds.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
startDate: asset.fileCreatedAt,
|
||||||
|
endDate: asset.fileCreatedAt,
|
||||||
|
albumId: album.id,
|
||||||
|
assetCount: 1,
|
||||||
|
lastModifiedAssetTimestamp: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
|
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||||
|
entityId: asset.id,
|
||||||
|
newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`,
|
||||||
|
oldPath: asset.originalPath,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle else condition from album startDate', async () => {
|
||||||
|
const asset = assetStub.storageAsset();
|
||||||
|
const user = userStub.user1;
|
||||||
|
const config = structuredClone(defaults);
|
||||||
|
config.storageTemplate.template =
|
||||||
|
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
|
||||||
|
|
||||||
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(asset);
|
||||||
|
|
||||||
|
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
|
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||||
|
entityId: asset.id,
|
||||||
|
newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
|
||||||
|
oldPath: asset.originalPath,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should migrate previously failed move from original path when it still exists', async () => {
|
it('should migrate previously failed move from original path when it still exists', async () => {
|
||||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ const storagePresets = [
|
|||||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||||
'{{y}}/{{MM}}/{{filename}}',
|
'{{y}}/{{MM}}/{{filename}}',
|
||||||
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
|
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
|
||||||
|
'{{#if album}}{{album-startDate-y}}/{{album}}{{else}}{{y}}/Other/{{MM}}{{/if}}/{{filename}}',
|
||||||
'{{y}}/{{MMM}}/{{filename}}',
|
'{{y}}/{{MMM}}/{{filename}}',
|
||||||
'{{y}}/{{MMMM}}/{{filename}}',
|
'{{y}}/{{MMMM}}/{{filename}}',
|
||||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||||
@ -54,6 +55,8 @@ interface RenderMetadata {
|
|||||||
filename: string;
|
filename: string;
|
||||||
extension: string;
|
extension: string;
|
||||||
albumName: string | null;
|
albumName: string | null;
|
||||||
|
albumStartDate: Date | null;
|
||||||
|
albumEndDate: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -62,6 +65,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
compiled: HandlebarsTemplateDelegate<any>;
|
compiled: HandlebarsTemplateDelegate<any>;
|
||||||
raw: string;
|
raw: string;
|
||||||
needsAlbum: boolean;
|
needsAlbum: boolean;
|
||||||
|
needsAlbumMetadata: boolean;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
private get template() {
|
private get template() {
|
||||||
@ -99,6 +103,8 @@ export class StorageTemplateService extends BaseService {
|
|||||||
filename: 'IMG_123',
|
filename: 'IMG_123',
|
||||||
extension: 'jpg',
|
extension: 'jpg',
|
||||||
albumName: 'album',
|
albumName: 'album',
|
||||||
|
albumStartDate: new Date(),
|
||||||
|
albumEndDate: new Date(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
|
this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
|
||||||
@ -255,9 +261,20 @@ export class StorageTemplateService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let albumName = null;
|
let albumName = null;
|
||||||
|
let albumStartDate = null;
|
||||||
|
let albumEndDate = null;
|
||||||
if (this.template.needsAlbum) {
|
if (this.template.needsAlbum) {
|
||||||
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
|
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
|
||||||
albumName = albums?.[0]?.albumName || null;
|
const album = albums?.[0];
|
||||||
|
if (album) {
|
||||||
|
albumName = album.albumName || null;
|
||||||
|
|
||||||
|
if (this.template.needsAlbumMetadata) {
|
||||||
|
const [metadata] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||||
|
albumStartDate = metadata?.startDate || null;
|
||||||
|
albumEndDate = metadata?.endDate || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = this.render(this.template.compiled, {
|
const storagePath = this.render(this.template.compiled, {
|
||||||
@ -265,6 +282,8 @@ export class StorageTemplateService extends BaseService {
|
|||||||
filename: sanitized,
|
filename: sanitized,
|
||||||
extension,
|
extension,
|
||||||
albumName,
|
albumName,
|
||||||
|
albumStartDate,
|
||||||
|
albumEndDate,
|
||||||
});
|
});
|
||||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||||
let destination = `${fullPath}.${extension}`;
|
let destination = `${fullPath}.${extension}`;
|
||||||
@ -323,12 +342,13 @@ export class StorageTemplateService extends BaseService {
|
|||||||
return {
|
return {
|
||||||
raw: template,
|
raw: template,
|
||||||
compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }),
|
compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }),
|
||||||
needsAlbum: template.includes('{{album}}'),
|
needsAlbum: template.includes('album'),
|
||||||
|
needsAlbumMetadata: template.includes('album-startDate') || template.includes('album-endDate'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private render(template: HandlebarsTemplateDelegate<any>, options: RenderMetadata) {
|
private render(template: HandlebarsTemplateDelegate<any>, options: RenderMetadata) {
|
||||||
const { filename, extension, asset, albumName } = options;
|
const { filename, extension, asset, albumName, albumStartDate, albumEndDate } = options;
|
||||||
const substitutions: Record<string, string> = {
|
const substitutions: Record<string, string> = {
|
||||||
filename,
|
filename,
|
||||||
ext: extension,
|
ext: extension,
|
||||||
@ -346,6 +366,15 @@ export class StorageTemplateService extends BaseService {
|
|||||||
|
|
||||||
for (const token of Object.values(storageTokens).flat()) {
|
for (const token of Object.values(storageTokens).flat()) {
|
||||||
substitutions[token] = dt.toFormat(token);
|
substitutions[token] = dt.toFormat(token);
|
||||||
|
if (albumName) {
|
||||||
|
// Use system time zone for album dates to ensure all assets get the exact same date.
|
||||||
|
substitutions['album-startDate-' + token] = albumStartDate
|
||||||
|
? DateTime.fromJSDate(albumStartDate, { zone: systemTimeZone }).toFormat(token)
|
||||||
|
: '';
|
||||||
|
substitutions['album-endDate-' + token] = albumEndDate
|
||||||
|
? DateTime.fromJSDate(albumEndDate, { zone: systemTimeZone }).toFormat(token)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return template(substitutions).replaceAll(/\/{2,}/gm, '/');
|
return template(substitutions).replaceAll(/\/{2,}/gm, '/');
|
||||||
|
@ -78,6 +78,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
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());
|
||||||
|
const albumStartTime = luxon.DateTime.fromISO(new Date('2021-12-31T05:32:41.750').toISOString());
|
||||||
|
const albumEndTime = luxon.DateTime.fromISO(new Date('2023-05-06T09:15:17.100').toISOString());
|
||||||
|
|
||||||
const dateTokens = [
|
const dateTokens = [
|
||||||
...templateOptions.yearOptions,
|
...templateOptions.yearOptions,
|
||||||
@ -91,6 +93,8 @@
|
|||||||
|
|
||||||
for (const token of dateTokens) {
|
for (const token of dateTokens) {
|
||||||
substitutions[token] = dt.toFormat(token);
|
substitutions[token] = dt.toFormat(token);
|
||||||
|
substitutions['album-startDate-' + token] = albumStartTime.toFormat(token);
|
||||||
|
substitutions['album-endDate-' + token] = albumEndTime.toFormat(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return template(substitutions);
|
return template(substitutions);
|
||||||
|
@ -29,6 +29,14 @@
|
|||||||
<li>{`{{assetId}}`} - Asset ID</li>
|
<li>{`{{assetId}}`} - Asset ID</li>
|
||||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||||
<li>{`{{album}}`} - Album Name</li>
|
<li>{`{{album}}`} - Album Name</li>
|
||||||
|
<li>
|
||||||
|
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
|
||||||
|
{$t('admin.storage_template_date_time_sample', { values: { date: '2021-12-31T05:32:41.750' } })}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{`{{album-endDate-x}}`} - Album End Date and Time (e.g. album-endDate-MM).
|
||||||
|
{$t('admin.storage_template_date_time_sample', { values: { date: '2023-05-06T09:15:17.100' } })}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user