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 3114e3676..8fc9ce331 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 75d579e1e..ce66412e6 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 73a8ec4d0..6e8101bd0 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 a9d346b15..324d12fcb 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 720323cd1..e2f9bec1e 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 175d609bd..68dc98e80 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 2164b1acb..538fb9f01 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 7ccd0d644..6a328b1f6 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 b4a398b57..faa1db4af 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 af820e686..0b4be5cba 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 ce8ecac72..6571a5ac2 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 7aaa46107..b7001e217 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 5204835d1..dc53e4f51 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 b8f6d6fbb..b50a468b0 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 10800bfb8..d15a8a3e8 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()),