mirror of
https://github.com/immich-app/immich.git
synced 2026-04-15 05:31:46 -04:00
This commit is contained in:
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user