From 9c5357422ee3b4b991fdf0d9cd9b033a1cce7360 Mon Sep 17 00:00:00 2001 From: Mees Frensel Date: Fri, 5 Dec 2025 11:37:48 +0100 Subject: [PATCH] fix: set duration to null when not present --- e2e/src/generators/timeline/rest-response.ts | 2 +- mobile/lib/domain/services/search.service.dart | 2 +- mobile/lib/entities/asset.entity.dart | 2 +- mobile/openapi/lib/model/asset_response_dto.dart | 11 ++++++++--- .../lib/model/time_bucket_asset_response_dto.dart | 2 +- open-api/immich-openapi-specs.json | 4 +++- open-api/typescript-sdk/src/fetch-client.ts | 5 +++-- server/src/controllers/asset-media.controller.spec.ts | 1 - server/src/dtos/asset-response.dto.ts | 7 ++++--- server/src/dtos/time-bucket.dto.ts | 2 +- server/src/services/asset-media.service.spec.ts | 3 +-- server/test/fixtures/shared-link.stub.ts | 4 ++-- .../lib/components/asset-viewer/photo-viewer.svelte | 3 +-- .../lib/components/assets/thumbnail/thumbnail.svelte | 4 ++-- web/src/lib/utils/file-uploader.ts | 1 - web/src/test-data/factories/asset-factory.ts | 4 ++-- 16 files changed, 31 insertions(+), 26 deletions(-) diff --git a/e2e/src/generators/timeline/rest-response.ts b/e2e/src/generators/timeline/rest-response.ts index 6fcfe52fc2..0461e5a7c9 100644 --- a/e2e/src/generators/timeline/rest-response.ts +++ b/e2e/src/generators/timeline/rest-response.ts @@ -334,7 +334,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/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index 6ccc5a97bf..4b23c3f3db 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -69,7 +69,7 @@ extension on AssetResponseDto { api.AssetVisibility.locked => AssetVisibility.locked, _ => AssetVisibility.timeline, }, - durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + durationInSeconds: duration?.toDuration()?.inSeconds ?? 0, height: exifInfo?.exifImageHeight?.toInt(), width: exifInfo?.exifImageWidth?.toInt(), isFavorite: isFavorite, diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 0d549457a1..918e7f1091 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -24,7 +24,7 @@ class Asset { fileCreatedAt = remote.fileCreatedAt, fileModifiedAt = remote.fileModifiedAt, updatedAt = remote.updatedAt, - durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, + durationInSeconds = remote.duration?.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), fileName = remote.originalFileName, height = remote.exifInfo?.exifImageHeight?.toInt(), diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 8d49986359..3556a4d22f 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -59,7 +59,8 @@ class AssetResponseDto { String? duplicateId; - 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 @@ -184,7 +185,7 @@ class AssetResponseDto { (deviceAssetId.hashCode) + (deviceId.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + - (duration.hashCode) + + (duration == null ? 0 : duration!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) + (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + @@ -226,7 +227,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 { @@ -302,7 +307,7 @@ class AssetResponseDto { deviceAssetId: mapValueOfType(json, r'deviceAssetId')!, deviceId: mapValueOfType(json, r'deviceId')!, 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 58032b7c51..6a928ecfd1 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 (ISO 8601 format, without timezone) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e21cf27beb..899469cbe7 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15686,6 +15686,8 @@ "type": "string" }, "duration": { + "description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)", + "nullable": true, "type": "string" }, "exifInfo": { @@ -22314,7 +22316,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 7afee42e2c..23deae57a8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -342,7 +342,8 @@ export type AssetResponseDto = { deviceAssetId: string; deviceId: string; duplicateId?: string | null; - 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; @@ -1665,7 +1666,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 (ISO 8601 format, without timezone) */ fileCreatedAt: string[]; diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index c2f6aeacef..0a1680abf3 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -14,7 +14,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 e228cd8f9f..33d4c565cc 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -31,7 +31,8 @@ export class SanitizedAssetResponseDto { example: '2024-01-15T14:30:00.000Z', }) localDateTime!: Date; - duration!: string; + @ApiProperty({ description: 'Video/gif duration in hh:mm:ss.SSS format (null for static images)' }) + duration!: string | null; livePhotoVideoId?: string | null; hasMetadata!: boolean; } @@ -187,7 +188,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, localDateTime: entity.localDateTime, - duration: entity.duration ?? '0:00:00.00000', + duration: entity.duration, livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, }; @@ -215,7 +216,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset 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 58772da00b..7ed4d31e33 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -147,7 +147,7 @@ export class TimeBucketAssetResponseDto { @ApiProperty({ type: 'array', items: { type: 'string', nullable: true }, - 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)', }) duration!: (string | null)[]; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 95eb8b3c97..c9abd1b2ab 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -145,7 +145,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 replaceDto = Object.freeze({ @@ -167,7 +166,7 @@ const assetEntity = Object.freeze({ updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, encodedVideoPath: '', - duration: '0:00:00.000000', + duration: null, files: [] as AssetFile[], exifInfo: { latitude: 49.533_547, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 19a62ad193..df27ab852c 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -62,7 +62,7 @@ const assetResponse: AssetResponseDto = { updatedAt: today, isFavorite: false, isArchived: false, - duration: '0:00:00.00000', + duration: null, exifInfo: assetInfo, livePhotoVideoId: null, tags: [], @@ -80,7 +80,7 @@ const assetResponseWithoutMetadata = { originalMimeType: 'image/jpeg', thumbhash: null, localDateTime: today, - duration: '0:00:00.00000', + duration: null, livePhotoVideoId: null, hasMetadata: false, } as AssetResponseDto; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2607f6de79..5ed067fc79 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -157,8 +157,7 @@ // when true, will force loading of the original image let forceUseOriginal: boolean = $derived( - (asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) || - $photoZoomState.currentZoom > 1, + (asset.type === AssetTypeEnum.Image && !!asset.duration) || $photoZoomState.currentZoom > 1, ); const targetImageSize = $derived.by(() => { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 38d734fc22..54a2b66fbc 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -294,7 +294,7 @@ {/if} - {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} + {#if asset.isImage && !!asset.duration}
@@ -363,7 +363,7 @@ playbackOnIconHover={!$playVideoThumbnailOnHover} />
- {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver} + {:else if asset.isImage && asset.duration && mouseOver}
diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 516d682625..5fd73b6fc3 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -136,7 +136,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 88316ccafb..e0e2678645 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -23,7 +23,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()), @@ -42,7 +42,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()),