fix(server): render storage template date/time tokens in UTC (#24350) (#26917)

This commit is contained in:
Miguel Raposo 2026-04-14 17:45:14 +01:00 committed by GitHub
parent a001adf14a
commit e4e2f586b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 59 additions and 8 deletions

View File

@ -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.
Date and time variables in storage templates are rendered in the server's local timezone.
```bash title="Default template"
Year/Year-Month-Day/Filename.Extension
```

View File

@ -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 () => {
const user = UserFactory.create();
const asset = AssetFactory.from({

View File

@ -413,20 +413,16 @@ export class StorageTemplateService extends BaseService {
lensModel: lensModel ?? '',
};
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const zone = asset.timeZone || systemTimeZone;
const dt = DateTime.fromJSDate(asset.fileCreatedAt, { zone });
const dt = DateTime.fromJSDate(asset.fileCreatedAt);
for (const token of Object.values(storageTokens).flat()) {
substitutions[token] = dt.toFormat(token);
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
? DateTime.fromJSDate(albumStartDate, { zone: systemTimeZone }).toFormat(token)
: '';
substitutions['album-endDate-' + token] = albumEndDate
? DateTime.fromJSDate(albumEndDate, { zone: systemTimeZone }).toFormat(token)
? DateTime.fromJSDate(albumStartDate).toFormat(token)
: '';
substitutions['album-endDate-' + token] = albumEndDate ? DateTime.fromJSDate(albumEndDate).toFormat(token) : '';
}
}