fix(web): preserve local time for browser uploads
parent
fd7ddfef54
commit
c10783445b
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16490,6 +16490,10 @@
|
|||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"timeZone": {
|
||||
"description": "IANA time zone of the upload client",
|
||||
"type": "string"
|
||||
},
|
||||
"visibility": {
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue