From e4e2f586b5796b2c6cfb31583f07b3ce907d3841 Mon Sep 17 00:00:00 2001 From: Miguel Raposo <74465787+migpovrap@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:45:14 +0100 Subject: [PATCH] fix(server): render storage template date/time tokens in UTC (#24350) (#26917) --- docs/docs/partials/_storage-template.md | 2 + .../services/storage-template.service.spec.ts | 53 +++++++++++++++++++ .../src/services/storage-template.service.ts | 12 ++--- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/docs/docs/partials/_storage-template.md b/docs/docs/partials/_storage-template.md index 84236e0ac1..1cd9572c11 100644 --- a/docs/docs/partials/_storage-template.md +++ b/docs/docs/partials/_storage-template.md @@ -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 ``` diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 9d7262246c..8f11a1dfa2 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -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({ diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 3d1bc8f835..acdcc868b2 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -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) : ''; } }