parent
a001adf14a
commit
e4e2f586b5
|
|
@ -6,6 +6,8 @@ You can read more about the differences between storage template engine on and o
|
||||||
|
|
||||||
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
|
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
|
||||||
|
|
||||||
|
Date and time variables in storage templates are rendered in the server's local timezone.
|
||||||
|
|
||||||
```bash title="Default template"
|
```bash title="Default template"
|
||||||
Year/Year-Month-Day/Filename.Extension
|
Year/Year-Month-Day/Filename.Extension
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,59 @@ describe(StorageTemplateService.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render storage datetime tokens in server timezone to preserve chronological filename ordering across time zones', async () => {
|
||||||
|
const user = UserFactory.create();
|
||||||
|
const assetBerlin = AssetFactory.from({
|
||||||
|
fileCreatedAt: new Date('2025-12-02T14:00:00.000Z'),
|
||||||
|
originalFileName: 'A.jpg',
|
||||||
|
})
|
||||||
|
.owner(user)
|
||||||
|
.exif({ timeZone: 'Europe/Berlin' })
|
||||||
|
.build();
|
||||||
|
const assetLondon = AssetFactory.from({
|
||||||
|
fileCreatedAt: new Date('2025-12-02T14:55:00.000Z'),
|
||||||
|
originalFileName: 'B.jpg',
|
||||||
|
})
|
||||||
|
.owner(user)
|
||||||
|
.exif({ timeZone: 'Europe/London' })
|
||||||
|
.build();
|
||||||
|
const config = structuredClone(defaults);
|
||||||
|
config.storageTemplate.template = '{{y}}{{MM}}{{dd}}_{{HH}}{{mm}}{{ss}}/{{filename}}';
|
||||||
|
sut.onConfigInit({ newConfig: config });
|
||||||
|
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(assetBerlin));
|
||||||
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(assetLondon));
|
||||||
|
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetBerlin.id })).resolves.toBe(JobStatus.Success);
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetLondon.id })).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
|
const formatStorageDateTime = (date: Date) => {
|
||||||
|
const year = date.getFullYear().toString();
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const hour = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minute = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
const second = date.getSeconds().toString().padStart(2, '0');
|
||||||
|
return `${year}${month}${day}_${hour}${minute}${second}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(mocks.move.create).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
entityId: assetBerlin.id,
|
||||||
|
newPath: `/data/library/${user.id}/${formatStorageDateTime(assetBerlin.fileCreatedAt)}/A.jpg`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.move.create).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
entityId: assetLondon.id,
|
||||||
|
newPath: `/data/library/${user.id}/${formatStorageDateTime(assetLondon.fileCreatedAt)}/B.jpg`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const asset = AssetFactory.from({
|
const asset = AssetFactory.from({
|
||||||
|
|
|
||||||
|
|
@ -413,20 +413,16 @@ export class StorageTemplateService extends BaseService {
|
||||||
lensModel: lensModel ?? '',
|
lensModel: lensModel ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const dt = DateTime.fromJSDate(asset.fileCreatedAt);
|
||||||
const zone = asset.timeZone || systemTimeZone;
|
|
||||||
const dt = DateTime.fromJSDate(asset.fileCreatedAt, { zone });
|
|
||||||
|
|
||||||
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) {
|
if (albumName) {
|
||||||
// Use system time zone for album dates to ensure all assets get the exact same date.
|
// Album date tokens are rendered in the server time zone to match storage template datetime behavior.
|
||||||
substitutions['album-startDate-' + token] = albumStartDate
|
substitutions['album-startDate-' + token] = albumStartDate
|
||||||
? DateTime.fromJSDate(albumStartDate, { zone: systemTimeZone }).toFormat(token)
|
? DateTime.fromJSDate(albumStartDate).toFormat(token)
|
||||||
: '';
|
|
||||||
substitutions['album-endDate-' + token] = albumEndDate
|
|
||||||
? DateTime.fromJSDate(albumEndDate, { zone: systemTimeZone }).toFormat(token)
|
|
||||||
: '';
|
: '';
|
||||||
|
substitutions['album-endDate-' + token] = albumEndDate ? DateTime.fromJSDate(albumEndDate).toFormat(token) : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue