diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 090eebdf85d3d..ced0230f3ee0c 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -31,6 +31,7 @@ class AssetResponseDto { this.livePhotoVideoId, required this.localDateTime, required this.originalFileName, + required this.originalMimeType, required this.originalPath, this.owner, required this.ownerId, @@ -91,6 +92,8 @@ class AssetResponseDto { String originalFileName; + String originalMimeType; + String originalPath; /// @@ -151,6 +154,7 @@ class AssetResponseDto { other.livePhotoVideoId == livePhotoVideoId && other.localDateTime == localDateTime && other.originalFileName == originalFileName && + other.originalMimeType == originalMimeType && other.originalPath == originalPath && other.owner == owner && other.ownerId == ownerId && @@ -187,6 +191,7 @@ class AssetResponseDto { (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (localDateTime.hashCode) + (originalFileName.hashCode) + + (originalMimeType.hashCode) + (originalPath.hashCode) + (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + @@ -203,7 +208,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -241,6 +246,7 @@ class AssetResponseDto { } json[r'localDateTime'] = this.localDateTime.toUtc().toIso8601String(); json[r'originalFileName'] = this.originalFileName; + json[r'originalMimeType'] = this.originalMimeType; json[r'originalPath'] = this.originalPath; if (this.owner != null) { json[r'owner'] = this.owner; @@ -304,6 +310,7 @@ class AssetResponseDto { livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), localDateTime: mapDateTime(json, r'localDateTime', r'')!, originalFileName: mapValueOfType(json, r'originalFileName')!, + originalMimeType: mapValueOfType(json, r'originalMimeType')!, originalPath: mapValueOfType(json, r'originalPath')!, owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, @@ -379,6 +386,7 @@ class AssetResponseDto { 'isTrashed', 'localDateTime', 'originalFileName', + 'originalMimeType', 'originalPath', 'ownerId', 'resized', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c2850b3a28f65..84a1a7a1ce66c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7708,6 +7708,9 @@ "originalFileName": { "type": "string" }, + "originalMimeType": { + "type": "string" + }, "originalPath": { "type": "string" }, @@ -7782,6 +7785,7 @@ "isTrashed", "localDateTime", "originalFileName", + "originalMimeType", "originalPath", "ownerId", "resized", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 168430bc843a3..d772cb6245b71 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -182,6 +182,7 @@ export type AssetResponseDto = { livePhotoVideoId?: string | null; localDateTime: string; originalFileName: string; + originalMimeType: string; originalPath: string; owner?: UserResponseDto; ownerId: string; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index a0095f954311f..d75a0c632937c 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -13,12 +13,14 @@ import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; +import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { id!: string; @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type!: AssetType; thumbhash!: string | null; + originalMimeType!: string; resized!: boolean; localDateTime!: Date; duration!: string; @@ -87,6 +89,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, + originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash?.toString('base64') ?? null, localDateTime: entity.localDateTime, resized: !!entity.previewPath, @@ -107,6 +110,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As type: entity.type, originalPath: entity.originalPath, originalFileName: entity.originalFileName, + originalMimeType: mimeTypes.lookup(entity.originalFileName), resized: !!entity.previewPath, thumbhash: entity.thumbhash?.toString('base64') ?? null, fileCreatedAt: entity.fileCreatedAt, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index acea121609d83..4d661bc571c77 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -52,6 +52,7 @@ const assetResponse: AssetResponseDto = { ownerId: 'user_id_1', deviceId: 'device_id_1', type: AssetType.VIDEO, + originalMimeType: 'image/jpeg', originalPath: 'fake_path/jpeg', originalFileName: 'asset_1.jpeg', resized: false, @@ -82,6 +83,7 @@ const assetResponse: AssetResponseDto = { const assetResponseWithoutMetadata = { id: 'id_1', type: AssetType.VIDEO, + originalMimeType: 'image/jpeg', resized: false, thumbhash: null, localDateTime: today, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts new file mode 100644 index 0000000000000..81741cc4898d0 --- /dev/null +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -0,0 +1,49 @@ +import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; +import * as utils from '$lib/utils'; +import { AssetMediaSize } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { render } from '@testing-library/svelte'; +import type { MockInstance } from 'vitest'; + +vi.mock('$lib/utils', async (originalImport) => { + const meta = await originalImport(); + return { + ...meta, + getAssetOriginalUrl: vi.fn(), + getAssetThumbnailUrl: vi.fn(), + }; +}); + +describe('PhotoViewer component', () => { + let getAssetOriginalUrlSpy: MockInstance; + let getAssetThumbnailUrlSpy: MockInstance; + + beforeAll(() => { + getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl'); + getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('loads the thumbnail', () => { + const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + checksum: asset.checksum, + }); + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads the original image for gifs', () => { + const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + }); +}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 384b3ebc1917d..47d92b0d53321 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -38,7 +38,8 @@ $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; $: useOriginalImage = useOriginalByDefault || forceUseOriginal; // when true, will force loading of the original image - $: forceUseOriginal = forceUseOriginal || ($photoZoomState.currentZoom > 1 && isWebCompatible); + $: forceUseOriginal = + forceUseOriginal || asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible); $: preload(useOriginalImage, preloadAssets); $: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index b6624770ad655..9fa851aa39115 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -257,20 +257,20 @@ export function getAssetRatio(asset: AssetResponseDto) { } // list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg -const supportedImageExtensions = new Set(['apng', 'avif', 'gif', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp', 'png', 'webp']); +const supportedImageMimeTypes = new Set([ + 'image/apng', + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); /** * Returns true if the asset is an image supported by web browsers, false otherwise */ export function isWebCompatibleImage(asset: AssetResponseDto): boolean { - // originalPath is undefined when public shared link has metadata option turned off - if (!asset.originalPath) { - return false; - } - - const imgExtension = getFilenameExtension(asset.originalPath); - - return supportedImageExtensions.has(imgExtension); + return supportedImageMimeTypes.has(asset.originalMimeType); } export const getAssetType = (type: AssetTypeEnum) => { diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 6b1e1a6ce3371..e76138fe59488 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -11,6 +11,7 @@ export const assetFactory = Sync.makeFactory({ type: Sync.each(() => faker.helpers.enumValue(AssetTypeEnum)), originalPath: Sync.each(() => faker.system.filePath()), originalFileName: Sync.each(() => faker.system.fileName()), + originalMimeType: Sync.each(() => faker.system.mimeType()), resized: true, thumbhash: Sync.each(() => faker.string.alphanumeric(28)), fileCreatedAt: Sync.each(() => faker.date.past().toISOString()),