From 7d181f0686aa70f910bfa2f985a8d5471995b95b Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:57:10 +0200 Subject: [PATCH] fix!: set duration to null when not present (#26982) --- e2e/src/ui/generators/timeline/rest-response.ts | 2 +- e2e/src/ui/mock-network/broken-asset-network.ts | 2 +- mobile/lib/extensions/asset_extensions.dart | 4 ++-- mobile/openapi/lib/model/asset_response_dto.dart | 12 ++++++++---- .../lib/model/time_bucket_asset_response_dto.dart | 2 +- open-api/immich-openapi-specs.json | 5 +++-- open-api/typescript-sdk/src/fetch-client.ts | 6 +++--- .../src/controllers/asset-media.controller.spec.ts | 1 - server/src/dtos/asset-response.dto.ts | 6 +++--- server/src/dtos/time-bucket.dto.ts | 4 +++- server/src/services/asset-media.service.spec.ts | 3 +-- .../lib/components/assets/thumbnail/thumbnail.svelte | 4 ++-- web/src/lib/utils.ts | 2 +- web/src/lib/utils/file-uploader.ts | 1 - web/src/test-data/factories/asset-factory.ts | 4 ++-- 15 files changed, 31 insertions(+), 27 deletions(-) diff --git a/e2e/src/ui/generators/timeline/rest-response.ts b/e2e/src/ui/generators/timeline/rest-response.ts index 3114e3676d..8fc9ce331d 100644 --- a/e2e/src/ui/generators/timeline/rest-response.ts +++ b/e2e/src/ui/generators/timeline/rest-response.ts @@ -332,7 +332,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons isArchived: false, isTrashed: asset.isTrashed, visibility: asset.visibility, - duration: asset.duration || '0:00:00.00000', + duration: asset.duration, exifInfo, livePhotoVideoId: asset.livePhotoVideoId, tags: [], diff --git a/e2e/src/ui/mock-network/broken-asset-network.ts b/e2e/src/ui/mock-network/broken-asset-network.ts index 75d579e1ef..ce66412e61 100644 --- a/e2e/src/ui/mock-network/broken-asset-network.ts +++ b/e2e/src/ui/mock-network/broken-asset-network.ts @@ -40,7 +40,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => { isArchived: false, isTrashed: false, visibility: AssetVisibility.Timeline, - duration: '0:00:00.00000', + duration: null, exifInfo: { make: null, model: null, diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart index 73a8ec4d05..6e8101bd04 100644 --- a/mobile/lib/extensions/asset_extensions.dart +++ b/mobile/lib/extensions/asset_extensions.dart @@ -14,7 +14,7 @@ extension DTOToAsset on api.AssetResponseDto { updatedAt: updatedAt, ownerId: ownerId, visibility: visibility.toAssetVisibility(), - durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + durationInSeconds: duration?.toDuration()?.inSeconds ?? 0, height: height?.toInt(), width: width?.toInt(), isFavorite: isFavorite, @@ -36,7 +36,7 @@ extension DTOToAsset on api.AssetResponseDto { updatedAt: updatedAt, ownerId: ownerId, visibility: visibility.toAssetVisibility(), - durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + durationInSeconds: duration?.toDuration()?.inSeconds ?? 0, height: height?.toInt(), width: width?.toInt(), isFavorite: isFavorite, diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index a9d346b155..324d12fcbf 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -57,8 +57,8 @@ class AssetResponseDto { /// Duplicate group ID String? duplicateId; - /// Video duration (for videos) - String duration; + /// Video/gif duration in hh:mm:ss.SSS format (null for static images) + String? duration; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -209,7 +209,7 @@ class AssetResponseDto { (checksum.hashCode) + (createdAt.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + - (duration.hashCode) + + (duration == null ? 0 : duration!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) + (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + @@ -252,7 +252,11 @@ class AssetResponseDto { } else { // json[r'duplicateId'] = null; } + if (this.duration != null) { json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } if (this.exifInfo != null) { json[r'exifInfo'] = this.exifInfo; } else { @@ -337,7 +341,7 @@ class AssetResponseDto { checksum: mapValueOfType(json, r'checksum')!, createdAt: mapDateTime(json, r'createdAt', r'')!, duplicateId: mapValueOfType(json, r'duplicateId'), - duration: mapValueOfType(json, r'duration')!, + duration: mapValueOfType(json, r'duration'), exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 720323cd14..e2f9bec1ec 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -39,7 +39,7 @@ class TimeBucketAssetResponseDto { /// Array of country names extracted from EXIF GPS data List country; - /// Array of video durations in HH:MM:SS format (null for images) + /// Array of video/gif durations in hh:mm:ss.SSS format (null for static images) List duration; /// Array of file creation timestamps in UTC diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 175d609bd3..68dc98e807 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16640,7 +16640,8 @@ "type": "string" }, "duration": { - "description": "Video duration (for videos)", + "description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)", + "nullable": true, "type": "string" }, "exifInfo": { @@ -24911,7 +24912,7 @@ "type": "array" }, "duration": { - "description": "Array of video durations in HH:MM:SS format (null for images)", + "description": "Array of video/gif durations in hh:mm:ss.SSS format (null for static images)", "items": { "nullable": true, "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2164b1acb4..538fb9f01a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -856,8 +856,8 @@ export type AssetResponseDto = { createdAt: string; /** Duplicate group ID */ duplicateId?: string | null; - /** Video duration (for videos) */ - duration: string; + /** Video/gif duration in hh:mm:ss.SSS format (null for static images) */ + duration: string | null; exifInfo?: ExifResponseDto; /** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */ fileCreatedAt: string; @@ -2671,7 +2671,7 @@ export type TimeBucketAssetResponseDto = { city: (string | null)[]; /** Array of country names extracted from EXIF GPS data */ country: (string | null)[]; - /** Array of video durations in HH:MM:SS format (null for images) */ + /** Array of video/gif durations in hh:mm:ss.SSS format (null for static images) */ duration: (string | null)[]; /** Array of file creation timestamps in UTC */ fileCreatedAt: string[]; diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index 7ccd0d644d..6a328b1f6d 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -12,7 +12,6 @@ const makeUploadDto = (options?: { omit: string }): Record => { fileCreatedAt: new Date().toISOString(), fileModifiedAt: new Date().toISOString(), isFavorite: 'false', - duration: '0:00:00.000000', }; const omit = options?.omit; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index b4a398b573..faa1db4afb 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -47,7 +47,7 @@ const SanitizedAssetResponseSchema = z .describe( 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', ), - duration: z.string().describe('Video duration (for videos)'), + duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'), hasMetadata: z.boolean().describe('Whether asset has metadata'), width: z.number().min(0).nullable().describe('Asset width'), @@ -221,7 +221,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, localDateTime: asDateString(entity.localDateTime), - duration: entity.duration ?? '0:00:00.00000', + duration: entity.duration, livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, width: entity.width, @@ -251,7 +251,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, visibility: entity.visibility, - duration: entity.duration ?? '0:00:00.00000', + duration: entity.duration, exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index af820e6868..0b4be5cba1 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -88,7 +88,9 @@ const TimeBucketAssetResponseSchema = z .describe( "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", ), - duration: z.array(z.string().nullable()).describe('Array of video durations in HH:MM:SS format (null for images)'), + duration: z + .array(z.string().nullable()) + .describe('Array of video/gif durations in hh:mm:ss.SSS format (null for static images)'), stack: z .array(stackTupleSchema) .optional() diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index ce8ecac72c..6571a5ac22 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -148,7 +148,6 @@ const createDto = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - duration: '0:00:00.000000', }) as AssetMediaCreateDto; const assetEntity = Object.freeze({ @@ -160,7 +159,7 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - duration: '0:00:00.000000', + duration: null, files: [] as AssetFile[], exifInfo: { latitude: 49.533_547, diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 7aaa46107d..b7001e217f 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -291,7 +291,7 @@ playbackOnIconHover={!$playVideoThumbnailOnHover} /> - {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver} + {:else if asset.isImage && asset.duration && mouseOver}
{/if} - {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} + {#if asset.isImage && asset.duration}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5204835d11..dc53e4f513 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -219,7 +219,7 @@ export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkRes } const forceUseOriginal = (asset: AssetResponseDto) => { - return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); + return asset.type === AssetTypeEnum.Image && asset.duration; }; export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index b8f6d6fbba..b50a468b00 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -155,7 +155,6 @@ async function fileUploader({ fileCreatedAt, fileModifiedAt: new Date(assetFile.lastModified).toISOString(), isFavorite: 'false', - duration: '0:00:00.000000', assetData: new File([assetFile], assetFile.name), })) { formData.append(key, value); diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 10800bfb8f..d15a8a3e84 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -21,7 +21,7 @@ export const assetFactory = Sync.makeFactory({ isFavorite: Sync.each(() => faker.datatype.boolean()), isArchived: false, isTrashed: false, - duration: '0:00:00.00000', + duration: null, checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), @@ -44,7 +44,7 @@ export const timelineAssetFactory = Sync.makeFactory({ isTrashed: false, isImage: true, isVideo: false, - duration: '0:00:00.00000', + duration: null, stack: null, projectionType: null, livePhotoVideoId: Sync.each(() => faker.string.uuid()),