fix(web): preserve local time for browser uploads

pull/28574/head
nick 2026-05-23 17:50:23 +08:00
parent fd7ddfef54
commit c10783445b
10 changed files with 134 additions and 6 deletions

View File

@ -1252,8 +1252,11 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] timeZone:
/// IANA time zone of the upload client
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? timeZone, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@ -1317,6 +1320,10 @@ class AssetsApi {
mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData);
}
if (timeZone != null) {
hasFields = true;
mp.fields[r'timeZone'] = parameterToString(timeZone);
}
if (visibility != null) {
hasFields = true;
mp.fields[r'visibility'] = parameterToString(visibility);
@ -1376,9 +1383,12 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] timeZone:
/// IANA time zone of the upload client
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? timeZone, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, timeZone: timeZone, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@ -16490,6 +16490,10 @@
"format": "binary",
"type": "string"
},
"timeZone": {
"description": "IANA time zone of the upload client",
"type": "string"
},
"visibility": {
"$ref": "#/components/schemas/AssetVisibility"
}

View File

@ -630,6 +630,8 @@ export type AssetMediaCreateDto = {
metadata?: AssetMetadataUpsertItemDto[];
/** Sidecar file data */
sidecarData?: Blob;
/** IANA time zone of the upload client */
timeZone?: string;
visibility?: AssetVisibility;
};
export type AssetMediaResponseDto = {

View File

@ -38,6 +38,7 @@ export enum UploadFieldName {
const AssetMediaBaseSchema = z.object({
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
timeZone: z.string().optional().describe('IANA time zone of the upload client'),
duration: z.coerce.number().int().min(0).optional().describe('Duration in milliseconds (for videos)'),
filename: z.string().optional().describe('Filename'),
/** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */

View File

@ -347,6 +347,32 @@ describe(AssetMediaService.name, () => {
);
});
it('should use the upload time zone for the initial local date time', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const dto = { ...createDto, timeZone: 'Asia/Shanghai' };
mocks.asset.create.mockResolvedValue(assetEntity);
await expect(sut.uploadAsset(authStub.user1, dto, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
expect(mocks.asset.create).toHaveBeenCalledWith(
expect.objectContaining({
fileCreatedAt: createDto.fileCreatedAt,
localDateTime: new Date('2022-06-20T07:41:36.910Z'),
}),
);
});
it('should handle a duplicate', async () => {
const file = {
uuid: 'random-uuid',

View File

@ -1,4 +1,5 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
@ -42,6 +43,15 @@ export interface AssetMediaRedirectResponse {
targetSize: AssetMediaSize | 'original';
}
const getLocalDateTime = (date: Date, timeZone?: string) => {
if (!timeZone) {
return date;
}
const localDateTime = DateTime.fromJSDate(date, { zone: 'UTC' }).setZone(timeZone);
return localDateTime.isValid ? localDateTime.setZone('UTC', { keepLocalTime: true }).toJSDate() : date;
};
@Injectable()
export class AssetMediaService extends BaseService {
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
@ -328,7 +338,7 @@ export class AssetMediaService extends BaseService {
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
localDateTime: getLocalDateTime(dto.fileCreatedAt, dto.timeZone),
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,

View File

@ -272,6 +272,42 @@ describe(MetadataService.name, () => {
});
});
it('should preserve the upload local time offset when missing exif', async () => {
const fileCreatedAt = new Date('2026-05-23T07:30:00.000Z');
const localDateTime = new Date('2026-05-23T15:30:00.000Z');
const asset = AssetFactory.from({
fileCreatedAt,
fileModifiedAt: fileCreatedAt,
localDateTime,
}).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileCreatedAt,
mtimeMs: fileCreatedAt.valueOf(),
birthtimeMs: fileCreatedAt.valueOf(),
} as Stats);
mockReadTags();
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
exif: expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
lockedPropertiesBehavior: 'skip',
}),
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: asset.id,
duration: null,
fileCreatedAt,
fileModifiedAt: fileCreatedAt,
localDateTime,
width: null,
height: null,
});
});
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
process.env.TZ = 'America/Los_Angeles';
const asset = AssetFactory.create();

View File

@ -41,6 +41,7 @@ import { Tasks } from 'src/utils/tasks';
const POSTGRES_INT_MAX = 2_147_483_647;
const POSTGRES_INT_MIN = -2_147_483_648;
const MINIMUM_TIME_ZONE_OFFSET_MILLISECONDS = 60_000;
/** look for a date from these tags (in order) */
const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
@ -134,6 +135,7 @@ type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['Re
type Dates = {
dateTimeOriginal: Date;
localDateTime: Date;
timeZone: string | null;
};
@Injectable()
@ -963,7 +965,7 @@ export class MetadataService extends BaseService {
}
private getDates(
asset: { id: string; originalPath: string; fileCreatedAt: Date },
asset: { id: string; originalPath: string; fileCreatedAt: Date; localDateTime: Date },
exifTags: ImmichTags,
stats: Stats,
) {
@ -1016,10 +1018,16 @@ export class MetadataService extends BaseService {
stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(),
),
);
const localOffset = asset.localDateTime.getTime() - asset.fileCreatedAt.getTime();
this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
);
dateTimeOriginal = localDateTime = earliestDate;
dateTimeOriginal = earliestDate;
// Upload clients can seed localDateTime from their local time zone when the file timestamp has no timezone
// information (for example, pasted screenshots). Preserve that offset during metadata extraction, while
// ignoring millisecond-level differences from tests or clock precision.
const meaningfulLocalOffset = Math.abs(localOffset) >= MINIMUM_TIME_ZONE_OFFSET_MILLISECONDS ? localOffset : 0;
localDateTime = DateTime.fromMillis(earliestDate.toMillis() + meaningfulLocalOffset);
}
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`);

View File

@ -9,6 +9,23 @@ import * as utils from '$lib/utils';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { fileUploadHandler } from './file-uploader';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
},
});
});
vi.mock('@immich/ui', () => ({
toastManager: {
warning: vi.fn(),
},
}));
describe('fileUploader error handling', () => {
const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const mockUserObject = { id: 'user-123', email: 'test@example.com' } as UserAdminResponseDto;
@ -69,4 +86,14 @@ describe('fileUploader error handling', () => {
expect(items.length).toBe(1);
expect(items[0].state).toBe(UploadState.STARTED);
});
it('should include the browser time zone in the upload request', async () => {
const uploadRequest = vi.spyOn(utils, 'uploadRequest').mockResolvedValue({ status: 200, data: mockUploadResponse });
await fileUploadHandler({ files: [mockFile] });
expect(uploadRequest).toHaveBeenCalled();
const formData = uploadRequest.mock.calls[0]![0].data as FormData;
expect(formData.get('timeZone')).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
});
});

View File

@ -168,6 +168,7 @@ async function fileUploader({
isLockedAssets = false,
}: FileUploaderParams): Promise<string | undefined> {
const fileCreatedAt = new Date(assetFile.lastModified).toISOString();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const $t = get(t);
const wasInitiallyLoggedIn = !!authManager.authenticated;
@ -183,6 +184,9 @@ async function fileUploader({
})) {
formData.append(key, value);
}
if (timeZone) {
formData.append('timeZone', timeZone);
}
if (isLockedAssets) {
formData.append('visibility', AssetVisibility.Locked);